mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-23 07:16:36 +01:00
Compare commits
1 Commits
feat/chapt
...
renovate/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5221d6ed75 |
73
.github/workflows/artifact-comment.yml
vendored
73
.github/workflows/artifact-comment.yml
vendored
@@ -188,17 +188,6 @@ jobs:
|
|||||||
if (latestAppsRun) {
|
if (latestAppsRun) {
|
||||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||||
|
|
||||||
// Map job names to our build targets. Declared outside the try so
|
|
||||||
// the catch fallback can reuse the same keys.
|
|
||||||
const jobMappings = {
|
|
||||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
|
||||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
|
||||||
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
|
|
||||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
|
|
||||||
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
|
|
||||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all jobs for this workflow run
|
// Get all jobs for this workflow run
|
||||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||||
@@ -227,6 +216,13 @@ jobs:
|
|||||||
return; // Exit early
|
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
|
// Create individual status for each job
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||||
const job = jobs.jobs.find(j =>
|
const job = jobs.jobs.find(j =>
|
||||||
@@ -240,9 +236,7 @@ jobs:
|
|||||||
conclusion: job.conclusion,
|
conclusion: job.conclusion,
|
||||||
url: job.html_url,
|
url: job.html_url,
|
||||||
runId: latestAppsRun.id,
|
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'})`);
|
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
||||||
} else {
|
} else {
|
||||||
@@ -253,30 +247,22 @@ jobs:
|
|||||||
conclusion: latestAppsRun.conclusion,
|
conclusion: latestAppsRun.conclusion,
|
||||||
url: latestAppsRun.html_url,
|
url: latestAppsRun.html_url,
|
||||||
runId: latestAppsRun.id,
|
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) {
|
} catch (error) {
|
||||||
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
||||||
// Fallback to workflow-level status for every build target.
|
// Fallback to workflow-level status
|
||||||
// Keys must match jobMappings / buildTargets statusKey values.
|
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
|
||||||
const fallbackStatus = {
|
|
||||||
name: latestAppsRun.name,
|
name: latestAppsRun.name,
|
||||||
status: latestAppsRun.status,
|
status: latestAppsRun.status,
|
||||||
conclusion: latestAppsRun.conclusion,
|
conclusion: latestAppsRun.conclusion,
|
||||||
url: latestAppsRun.html_url,
|
url: latestAppsRun.html_url,
|
||||||
runId: latestAppsRun.id,
|
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
|
// Collect artifacts if any job has completed successfully
|
||||||
@@ -367,12 +353,10 @@ jobs:
|
|||||||
|
|
||||||
// Process each expected build target individually
|
// Process each expected build target individually
|
||||||
const buildTargets = [
|
const buildTargets = [
|
||||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||||
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
|
||||||
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/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) {
|
for (const target of buildTargets) {
|
||||||
@@ -387,31 +371,16 @@ jobs:
|
|||||||
let status = '⏳ Pending';
|
let status = '⏳ Pending';
|
||||||
let downloadLink = '*Waiting for build...*';
|
let downloadLink = '*Waiting for build...*';
|
||||||
|
|
||||||
// tvOS builds are temporarily disabled until feat/tv-interface
|
// Special case for iOS TV - show as disabled
|
||||||
// is merged - show them as disabled instead of stuck pending.
|
if (target.name === 'iOS TV') {
|
||||||
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
|
||||||
status = '💤 Disabled';
|
status = '💤 Disabled';
|
||||||
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
downloadLink = '*Disabled for now*';
|
||||||
} else if (matchingStatus) {
|
} else if (matchingStatus) {
|
||||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||||
status = '✅ Complete';
|
status = '✅ Complete';
|
||||||
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
|
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';
|
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') {
|
} else if (matchingStatus.conclusion === 'failure') {
|
||||||
status = `❌ [Failed](${matchingStatus.url})`;
|
status = `❌ [Failed](${matchingStatus.url})`;
|
||||||
downloadLink = '*Build failed*';
|
downloadLink = '*Build failed*';
|
||||||
@@ -439,7 +408,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
|
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
commentBody += `\n`;
|
commentBody += `\n`;
|
||||||
|
|||||||
188
.github/workflows/build-apps.yml
vendored
188
.github/workflows/build-apps.yml
vendored
@@ -299,127 +299,67 @@ jobs:
|
|||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-ios-tv:
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
# build-ios-tv:
|
||||||
# Re-enable by removing the `false &&` prefix below.
|
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# runs-on: macos-26
|
||||||
runs-on: macos-26
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
name: 🍎 Build tvOS IPA
|
# permissions:
|
||||||
permissions:
|
# contents: read
|
||||||
contents: read
|
#
|
||||||
|
# steps:
|
||||||
steps:
|
# - name: 📥 Checkout code
|
||||||
- name: 📥 Checkout code
|
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
# with:
|
||||||
with:
|
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
# fetch-depth: 0
|
||||||
fetch-depth: 0
|
# submodules: recursive
|
||||||
submodules: recursive
|
# show-progress: false
|
||||||
show-progress: false
|
#
|
||||||
|
# - name: 🍞 Setup Bun
|
||||||
- name: 🍞 Setup Bun
|
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
# with:
|
||||||
with:
|
# bun-version: latest
|
||||||
bun-version: latest
|
#
|
||||||
|
# - name: 💾 Cache Bun dependencies
|
||||||
- name: 💾 Cache Bun dependencies
|
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
# with:
|
||||||
with:
|
# path: ~/.bun/install/cache
|
||||||
path: ~/.bun/install/cache
|
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
# restore-keys: |
|
||||||
restore-keys: |
|
# ${{ runner.os }}-bun-cache
|
||||||
${{ runner.os }}-bun-cache
|
#
|
||||||
|
# - name: 📦 Install dependencies and reload submodules
|
||||||
- name: 📦 Install dependencies and reload submodules
|
# run: |
|
||||||
run: |
|
# bun install --frozen-lockfile
|
||||||
bun install --frozen-lockfile
|
# bun run submodule-reload
|
||||||
bun run submodule-reload
|
#
|
||||||
|
# - name: 🛠️ Generate project files
|
||||||
- name: 🛠️ Generate project files
|
# run: bun run prebuild:tv
|
||||||
run: bun run prebuild:tv
|
#
|
||||||
|
# - name: 🔧 Setup Xcode
|
||||||
- name: 🔧 Setup Xcode
|
# uses: maxim-lobanov/setup-xcode@v1
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
# with:
|
||||||
with:
|
# xcode-version: '26.0.1'
|
||||||
xcode-version: "26.2"
|
#
|
||||||
|
# - name: 🏗️ Setup EAS
|
||||||
- name: 🏗️ Setup EAS
|
# uses: expo/expo-github-action@main
|
||||||
uses: expo/expo-github-action@main
|
# with:
|
||||||
with:
|
# eas-version: latest
|
||||||
eas-version: latest
|
# token: ${{ secrets.EXPO_TOKEN }}
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
# eas-cache: true
|
||||||
eas-cache: true
|
#
|
||||||
|
# - name: 🚀 Build iOS app
|
||||||
- name: 🚀 Build iOS app
|
# env:
|
||||||
env:
|
# EXPO_TV: 1
|
||||||
EXPO_TV: 1
|
# run: eas build -p ios --local --non-interactive
|
||||||
run: eas build -p ios --local --non-interactive
|
#
|
||||||
|
# - name: 📅 Set date tag
|
||||||
- name: 📅 Set date tag
|
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
#
|
||||||
|
# - name: 📤 Upload IPA artifact
|
||||||
- name: 📤 Upload IPA artifact
|
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
# with:
|
||||||
with:
|
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||||
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
# path: build-*.ipa
|
||||||
path: build-*.ipa
|
# retention-days: 7
|
||||||
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@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
|
||||||
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@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
||||||
with:
|
|
||||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
|
||||||
path: build/*.ipa
|
|
||||||
retention-days: 7
|
|
||||||
|
|||||||
16
bun.lock
16
bun.lock
@@ -19,7 +19,7 @@
|
|||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.100.11",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -50,14 +50,14 @@
|
|||||||
"expo-system-ui": "~6.0.9",
|
"expo-system-ui": "~6.0.9",
|
||||||
"expo-task-manager": "14.0.9",
|
"expo-task-manager": "14.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^26.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "16.5.8",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
@@ -604,7 +604,7 @@
|
|||||||
|
|
||||||
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.100.11", "", { "dependencies": { "@tanstack/query-core": "5.100.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg=="],
|
||||||
|
|
||||||
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
||||||
|
|
||||||
@@ -1208,7 +1208,7 @@
|
|||||||
|
|
||||||
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
|
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
|
||||||
|
|
||||||
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="],
|
"i18next": ["i18next@25.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
@@ -1642,7 +1642,7 @@
|
|||||||
|
|
||||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
"react-i18next": ["react-i18next@16.5.8", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg=="],
|
||||||
|
|
||||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||||
|
|
||||||
@@ -2252,7 +2252,7 @@
|
|||||||
|
|
||||||
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="],
|
||||||
|
|
||||||
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||||
|
|
||||||
@@ -2444,8 +2444,6 @@
|
|||||||
|
|
||||||
"react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||||
|
|
||||||
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
|
||||||
|
|
||||||
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||||
|
|
||||||
"react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* A modal listing an item's chapters. Each row shows the chapter name and its
|
|
||||||
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
|
|
||||||
* chapter and closes the modal. Player-agnostic — the seek is injected.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FlatList, Modal, Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import {
|
|
||||||
currentChapterIndex,
|
|
||||||
formatChapterTime,
|
|
||||||
sortedChapters,
|
|
||||||
} from "@/utils/chapters";
|
|
||||||
|
|
||||||
interface ChapterListProps {
|
|
||||||
visible: boolean;
|
|
||||||
chapters: ChapterInfo[] | null | undefined;
|
|
||||||
/** Current playback position in milliseconds (to highlight the row). */
|
|
||||||
currentPositionMs: number;
|
|
||||||
/** Seek the player to this millisecond position. */
|
|
||||||
onSeek: (positionMs: number) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChapterList({
|
|
||||||
visible,
|
|
||||||
chapters,
|
|
||||||
currentPositionMs,
|
|
||||||
onSeek,
|
|
||||||
onClose,
|
|
||||||
}: ChapterListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const entries = sortedChapters(chapters);
|
|
||||||
const activeIndex = currentChapterIndex(currentPositionMs, chapters);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={onClose}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
backgroundColor: "#0009",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
maxHeight: "70%",
|
|
||||||
paddingBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontSize: 17, fontWeight: "700" }}>
|
|
||||||
{t("chapters.title")}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={onClose}
|
|
||||||
hitSlop={10}
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.close")}
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={24} color='#fff' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
|
||||||
data={entries}
|
|
||||||
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
|
||||||
renderItem={({ item, index }) => {
|
|
||||||
const positionMs = item.positionMs;
|
|
||||||
const isActive = index === activeIndex;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSeek(positionMs);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
backgroundColor: isActive ? "#a855f733" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: isActive ? "#a855f7" : "#fff",
|
|
||||||
fontSize: 15,
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.chapter.Name ||
|
|
||||||
t("chapters.chapter_number", { number: index + 1 })}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13, marginLeft: 12 }}>
|
|
||||||
{formatChapterTime(positionMs)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chapter tick marks drawn as an absolute overlay over a progress slider.
|
|
||||||
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
|
|
||||||
* so the slider underneath still receives touches.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
|
||||||
|
|
||||||
interface ChapterTicksProps {
|
|
||||||
chapters: ChapterInfo[] | null | undefined;
|
|
||||||
/** Total media duration in milliseconds. */
|
|
||||||
durationMs: number;
|
|
||||||
/** Tick colour. */
|
|
||||||
color?: string;
|
|
||||||
/** Tick height in px — slightly less than the slider track thickness. */
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChapterTicks({
|
|
||||||
chapters,
|
|
||||||
durationMs,
|
|
||||||
color = "#fff",
|
|
||||||
height = 6,
|
|
||||||
}: ChapterTicksProps) {
|
|
||||||
const markers = chapterMarkers(chapters, durationMs);
|
|
||||||
// One chapter (typically a single marker at 0) is not worth marking.
|
|
||||||
if (markers.length <= 1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
pointerEvents='none'
|
|
||||||
style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
|
|
||||||
>
|
|
||||||
{markers.map((marker, index) => (
|
|
||||||
<View
|
|
||||||
key={`${marker.positionMs}-${index}`}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${marker.percent}%`,
|
|
||||||
top: "50%",
|
|
||||||
marginTop: -height / 2,
|
|
||||||
height,
|
|
||||||
width: 1.5,
|
|
||||||
backgroundColor: color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type {
|
import type { FC } from "react";
|
||||||
BaseItemDto,
|
import { View } from "react-native";
|
||||||
ChapterInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { type FC, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
@@ -21,10 +13,6 @@ import { TrickplayBubble } from "./TrickplayBubble";
|
|||||||
|
|
||||||
interface BottomControlsProps {
|
interface BottomControlsProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
/** Item chapters, used for the tick overlay and chapter list. */
|
|
||||||
chapters?: ChapterInfo[] | null;
|
|
||||||
/** Total media duration in milliseconds. */
|
|
||||||
durationMs: number;
|
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isSliding: boolean;
|
isSliding: boolean;
|
||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
@@ -73,8 +61,6 @@ interface BottomControlsProps {
|
|||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
item,
|
item,
|
||||||
chapters,
|
|
||||||
durationMs,
|
|
||||||
showControls,
|
showControls,
|
||||||
isSliding,
|
isSliding,
|
||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
@@ -103,16 +89,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
time,
|
time,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
|
||||||
|
|
||||||
// Only expose chapter UI when there are at least two real markers.
|
|
||||||
const chapterMarkerList = useMemo(
|
|
||||||
() => chapterMarkers(chapters, durationMs),
|
|
||||||
[chapters, durationMs],
|
|
||||||
);
|
|
||||||
const hasChapters = chapterMarkerList.length > 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -155,18 +132,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row space-x-2 shrink-0'>
|
||||||
{hasChapters && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setChapterListVisible(true)}
|
|
||||||
hitSlop={10}
|
|
||||||
className='justify-center mr-4'
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.open")}
|
|
||||||
>
|
|
||||||
<Ionicons name='list' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -246,7 +212,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
<ChapterTicks chapters={chapters} durationMs={durationMs} />
|
|
||||||
</View>
|
</View>
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@@ -254,13 +219,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ChapterList
|
|
||||||
visible={chapterListVisible}
|
|
||||||
chapters={chapters}
|
|
||||||
currentPositionMs={currentTime}
|
|
||||||
onSeek={(ms) => handleSliderComplete(ms)}
|
|
||||||
onClose={() => setChapterListVisible(false)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -528,8 +528,6 @@ export const Controls: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<BottomControls
|
<BottomControls
|
||||||
item={item}
|
item={item}
|
||||||
chapters={item.Chapters}
|
|
||||||
durationMs={maxMs}
|
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
isSliding={isSliding}
|
isSliding={isSliding}
|
||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
||||||
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
"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": "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",
|
"prepare": "husky",
|
||||||
"typecheck": "node scripts/typecheck.js",
|
"typecheck": "node scripts/typecheck.js",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
@@ -40,7 +39,7 @@
|
|||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.100.11",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -71,14 +70,14 @@
|
|||||||
"expo-system-ui": "~6.0.9",
|
"expo-system-ui": "~6.0.9",
|
||||||
"expo-task-manager": "14.0.9",
|
"expo-task-manager": "14.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^26.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "16.5.8",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
|
|||||||
@@ -610,12 +610,6 @@
|
|||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel"
|
||||||
},
|
},
|
||||||
"chapters": {
|
|
||||||
"title": "Chapters",
|
|
||||||
"chapter_number": "Chapter {{number}}",
|
|
||||||
"open": "Open chapters",
|
|
||||||
"close": "Close chapters"
|
|
||||||
},
|
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No Items to Display",
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import {
|
|
||||||
chapterMarkers,
|
|
||||||
currentChapterIndex,
|
|
||||||
formatChapterTime,
|
|
||||||
sortedChapters,
|
|
||||||
} from "./chapters";
|
|
||||||
|
|
||||||
// Helper: a ChapterInfo with a start in milliseconds.
|
|
||||||
const ch = (ms: number, name?: string) => ({
|
|
||||||
StartPositionTicks: ms * 10000,
|
|
||||||
Name: name,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("chapterMarkers", () => {
|
|
||||||
test("maps chapters to position + percent", () => {
|
|
||||||
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
|
|
||||||
{ positionMs: 0, percent: 0 },
|
|
||||||
{ positionMs: 30_000, percent: 25 },
|
|
||||||
{ positionMs: 60_000, percent: 50 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("drops chapters past the duration", () => {
|
|
||||||
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
|
|
||||||
{ positionMs: 0, percent: 0 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns [] when duration is 0 or chapters missing", () => {
|
|
||||||
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
|
|
||||||
expect(chapterMarkers(null, 120_000)).toEqual([]);
|
|
||||||
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("excludes a chapter exactly at the duration", () => {
|
|
||||||
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
|
|
||||||
{ positionMs: 0, percent: 0 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("skips chapters with no StartPositionTicks", () => {
|
|
||||||
expect(
|
|
||||||
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
|
|
||||||
).toEqual([{ positionMs: 30_000, percent: 25 }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("currentChapterIndex", () => {
|
|
||||||
const chapters = [ch(0), ch(30_000), ch(60_000)];
|
|
||||||
test("returns the chapter containing the position", () => {
|
|
||||||
expect(currentChapterIndex(0, chapters)).toBe(0);
|
|
||||||
expect(currentChapterIndex(15_000, chapters)).toBe(0);
|
|
||||||
expect(currentChapterIndex(30_000, chapters)).toBe(1);
|
|
||||||
expect(currentChapterIndex(90_000, chapters)).toBe(2);
|
|
||||||
});
|
|
||||||
test("returns -1 before the first chapter and for no chapters", () => {
|
|
||||||
expect(currentChapterIndex(-5, chapters)).toBe(-1);
|
|
||||||
expect(currentChapterIndex(10_000, [])).toBe(-1);
|
|
||||||
expect(currentChapterIndex(10_000, null)).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sortedChapters", () => {
|
|
||||||
test("pairs each chapter with its ms start, sorted ascending", () => {
|
|
||||||
const a = ch(60_000, "C");
|
|
||||||
const b = ch(0, "A");
|
|
||||||
const c = ch(30_000, "B");
|
|
||||||
expect(sortedChapters([a, b, c])).toEqual([
|
|
||||||
{ chapter: b, positionMs: 0 },
|
|
||||||
{ chapter: c, positionMs: 30_000 },
|
|
||||||
{ chapter: a, positionMs: 60_000 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
test("returns [] for null/undefined", () => {
|
|
||||||
expect(sortedChapters(null)).toEqual([]);
|
|
||||||
expect(sortedChapters(undefined)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatChapterTime", () => {
|
|
||||||
test("formats m:ss and h:mm:ss", () => {
|
|
||||||
expect(formatChapterTime(65_000)).toBe("1:05");
|
|
||||||
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
|
|
||||||
expect(formatChapterTime(-100)).toBe("0:00");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
|
|
||||||
* unit-testable under `bun test`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { ticksToMs } from "@/utils/time";
|
|
||||||
|
|
||||||
export interface ChapterMarker {
|
|
||||||
/** Chapter start, in milliseconds. */
|
|
||||||
positionMs: number;
|
|
||||||
/** Chapter start as a percentage (0-100) of the media duration. */
|
|
||||||
percent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChapterEntry {
|
|
||||||
chapter: ChapterInfo;
|
|
||||||
/** Chapter start, in milliseconds. */
|
|
||||||
positionMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Chapters paired with their millisecond start, sorted ascending by start. */
|
|
||||||
export const sortedChapters = (
|
|
||||||
chapters: ChapterInfo[] | null | undefined,
|
|
||||||
): ChapterEntry[] =>
|
|
||||||
(chapters ?? [])
|
|
||||||
.filter((c) => c.StartPositionTicks != null)
|
|
||||||
.map((chapter) => ({
|
|
||||||
chapter,
|
|
||||||
positionMs: ticksToMs(chapter.StartPositionTicks),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.positionMs - b.positionMs);
|
|
||||||
|
|
||||||
/** Chapter start positions in milliseconds, ascending. */
|
|
||||||
export const chapterStartsMs = (
|
|
||||||
chapters: ChapterInfo[] | null | undefined,
|
|
||||||
): number[] =>
|
|
||||||
(chapters ?? [])
|
|
||||||
.filter((c) => c.StartPositionTicks != null)
|
|
||||||
.map((c) => ticksToMs(c.StartPositionTicks))
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
|
|
||||||
export const chapterMarkers = (
|
|
||||||
chapters: ChapterInfo[] | null | undefined,
|
|
||||||
durationMs: number,
|
|
||||||
): ChapterMarker[] => {
|
|
||||||
if (durationMs <= 0) return [];
|
|
||||||
return chapterStartsMs(chapters)
|
|
||||||
.filter((ms) => ms >= 0 && ms < durationMs)
|
|
||||||
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
|
|
||||||
export const currentChapterIndex = (
|
|
||||||
positionMs: number,
|
|
||||||
chapters: ChapterInfo[] | null | undefined,
|
|
||||||
): number => {
|
|
||||||
const starts = chapterStartsMs(chapters);
|
|
||||||
let index = -1;
|
|
||||||
for (let i = 0; i < starts.length; i++) {
|
|
||||||
if (positionMs >= starts[i]) index = i;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
|
|
||||||
export const formatChapterTime = (positionMs: number): string => {
|
|
||||||
const total = Math.max(0, Math.floor(positionMs / 1000));
|
|
||||||
const hours = Math.floor(total / 3600);
|
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
|
||||||
const seconds = total % 60;
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return hours > 0
|
|
||||||
? `${hours}:${pad(minutes)}:${pad(seconds)}`
|
|
||||||
: `${minutes}:${pad(seconds)}`;
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user