mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-22 23:06:39 +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) {
|
||||
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({
|
||||
@@ -227,6 +216,13 @@ 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 =>
|
||||
@@ -240,9 +236,7 @@ jobs:
|
||||
conclusion: job.conclusion,
|
||||
url: job.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: job.started_at || latestAppsRun.created_at,
|
||||
started_at: job.started_at,
|
||||
completed_at: job.completed_at
|
||||
created_at: job.started_at || latestAppsRun.created_at
|
||||
};
|
||||
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
||||
} else {
|
||||
@@ -253,30 +247,22 @@ jobs:
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at,
|
||||
started_at: latestAppsRun.run_started_at,
|
||||
completed_at: latestAppsRun.updated_at
|
||||
created_at: latestAppsRun.created_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
||||
// Fallback to workflow-level status for every build target.
|
||||
// Keys must match jobMappings / buildTargets statusKey values.
|
||||
const fallbackStatus = {
|
||||
// Fallback to workflow-level status
|
||||
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
|
||||
name: latestAppsRun.name,
|
||||
status: latestAppsRun.status,
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at,
|
||||
started_at: latestAppsRun.run_started_at,
|
||||
completed_at: latestAppsRun.updated_at
|
||||
created_at: latestAppsRun.created_at
|
||||
};
|
||||
for (const platform of Object.keys(jobMappings)) {
|
||||
buildStatuses[platform] = fallbackStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect artifacts if any job has completed successfully
|
||||
@@ -367,12 +353,10 @@ jobs:
|
||||
|
||||
// Process each expected build target individually
|
||||
const buildTargets = [
|
||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'iOS 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 }
|
||||
{ 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 }
|
||||
];
|
||||
|
||||
for (const target of buildTargets) {
|
||||
@@ -387,31 +371,16 @@ jobs:
|
||||
let status = '⏳ Pending';
|
||||
let downloadLink = '*Waiting for build...*';
|
||||
|
||||
// tvOS builds are temporarily disabled until feat/tv-interface
|
||||
// is merged - show them as disabled instead of stuck pending.
|
||||
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
||||
// Special case for iOS TV - show as disabled
|
||||
if (target.name === 'iOS TV') {
|
||||
status = '💤 Disabled';
|
||||
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||
downloadLink = '*Disabled for now*';
|
||||
} 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';
|
||||
|
||||
// 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}`;
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink})`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
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`;
|
||||
|
||||
188
.github/workflows/build-apps.yml
vendored
188
.github/workflows/build-apps.yml
vendored
@@ -299,127 +299,67 @@ jobs:
|
||||
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@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: 🏗️ 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
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@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
|
||||
# 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
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -19,7 +19,7 @@
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@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",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.31",
|
||||
@@ -50,14 +50,14 @@
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "14.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next": "^25.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": "17.0.8",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.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-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=="],
|
||||
|
||||
@@ -1208,7 +1208,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1642,7 +1642,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -2444,8 +2444,6 @@
|
||||
|
||||
"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/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,
|
||||
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
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 { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
@@ -21,10 +13,6 @@ import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
interface BottomControlsProps {
|
||||
item: BaseItemDto;
|
||||
/** Item chapters, used for the tick overlay and chapter list. */
|
||||
chapters?: ChapterInfo[] | null;
|
||||
/** Total media duration in milliseconds. */
|
||||
durationMs: number;
|
||||
showControls: boolean;
|
||||
isSliding: boolean;
|
||||
showRemoteBubble: boolean;
|
||||
@@ -73,8 +61,6 @@ interface BottomControlsProps {
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
item,
|
||||
chapters,
|
||||
durationMs,
|
||||
showControls,
|
||||
isSliding,
|
||||
showRemoteBubble,
|
||||
@@ -103,16 +89,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
time,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<View
|
||||
@@ -155,18 +132,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex flex-row items-center 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>
|
||||
)}
|
||||
<View className='flex flex-row space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
@@ -246,7 +212,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<ChapterTicks chapters={chapters} durationMs={durationMs} />
|
||||
</View>
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
@@ -254,13 +219,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ChapterList
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={currentTime}
|
||||
onSeek={(ms) => handleSliderComplete(ms)}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -528,8 +528,6 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<BottomControls
|
||||
item={item}
|
||||
chapters={item.Chapters}
|
||||
durationMs={maxMs}
|
||||
showControls={showControls}
|
||||
isSliding={isSliding}
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"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",
|
||||
@@ -40,7 +39,7 @@
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@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",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.31",
|
||||
@@ -71,14 +70,14 @@
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "14.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next": "^25.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": "17.0.8",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
|
||||
@@ -610,12 +610,6 @@
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
"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