mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-15 02:10:23 +01:00
Compare commits
1 Commits
autoskip
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a06319b0 |
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,13 +75,10 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
|
description: What version of Streamyfin are you using?
|
||||||
options:
|
options:
|
||||||
- 0.54.1
|
- 0.54.1
|
||||||
- 0.51.0
|
- 0.51.0
|
||||||
- 0.47.1
|
|
||||||
- 0.30.2
|
|
||||||
- 0.28.0
|
|
||||||
- Older
|
- Older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
121
.github/workflows/update-issue-form.yml
vendored
121
.github/workflows/update-issue-form.yml
vendored
@@ -1,102 +1,67 @@
|
|||||||
name: 🐛 Update Issue Form Versions
|
name: 🐛 Update Bug Report Template
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
# Only full releases populate the dropdown (no drafts/prereleases).
|
types: [published] # Run on every published release on any branch
|
||||||
types: [released]
|
|
||||||
schedule:
|
|
||||||
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Fixed group so a release event and the weekly cron can't race on the same
|
|
||||||
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: update-issue-form
|
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-issue-form:
|
update-bug-report:
|
||||||
name: 🔢 Populate version dropdown
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: "🟢 Setup Node.js"
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
node-version: '24.x'
|
||||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
cache: 'npm'
|
||||||
# PR would revert any form edits made on develop since that release.
|
|
||||||
ref: develop
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🔍 Extract minor version from app.json
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
id: minor
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
result-encoding: string
|
||||||
|
script: |
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const semver = require('semver');
|
||||||
|
const content = fs.readJsonSync('./app.json');
|
||||||
|
const version = content.expo.version;
|
||||||
|
const minorVersion = semver.minor(version);
|
||||||
|
return minorVersion.toString();
|
||||||
|
|
||||||
- name: 🔢 Populate version dropdown from GitHub releases
|
- name: 📝 Update bug report version
|
||||||
id: populate
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
run: bun scripts/update-issue-form.mjs
|
with:
|
||||||
env:
|
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
||||||
GH_TOKEN: ${{ github.token }}
|
dry_run: no-push
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: 📬 Create pull request
|
- name: ⚙️ Update bug report node version dropdown
|
||||||
id: cpr
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
|
with:
|
||||||
|
dropdown: _node_version
|
||||||
|
package: node
|
||||||
|
semver: '>=24.0.0'
|
||||||
|
dry_run: no-push
|
||||||
|
|
||||||
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci/update-issue-form
|
branch: ci-update-bug-report
|
||||||
base: develop
|
base: develop
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
labels: ⚙️ ci, 🤖 github-actions
|
labels: ⚙️ ci, 🤖 github-actions
|
||||||
commit-message: "chore: update issue form version dropdown"
|
title: 'chore(): Update bug report template to match release version'
|
||||||
title: "chore: update issue form version dropdown"
|
|
||||||
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
|
||||||
body: |
|
body: |
|
||||||
# 📦 Pull Request
|
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||||
|
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
## 📝 Description
|
|
||||||
|
|
||||||
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
|
|
||||||
|
|
||||||
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
|
|
||||||
|
|
||||||
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
|
||||||
|
|
||||||
N/A — automated maintenance.
|
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
|
||||||
|
|
||||||
N/A — issue-template metadata only, no app UI.
|
|
||||||
|
|
||||||
## ✅ Checklist
|
|
||||||
|
|
||||||
- [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
|
||||||
- [x] Verified that changes behave as expected for all platforms
|
|
||||||
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
|
||||||
- [x] No secrets, hardcoded credentials, or private config files are included
|
|
||||||
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
|
||||||
|
|
||||||
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
|
|
||||||
|
|
||||||
- name: 🔀 Enable auto-merge
|
|
||||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
|
|
||||||
# (GitHub anti-recursion), so the required checks stay "Expected" until a
|
|
||||||
# maintainer kicks them (close/reopen the PR, or push an empty commit).
|
|
||||||
# Auto-merge is still worth enabling: once checks run and reviews land,
|
|
||||||
# the PR merges itself.
|
|
||||||
run: |
|
|
||||||
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|
|
||||||
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
|
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
InactivityTimeout,
|
InactivityTimeout,
|
||||||
type MpvCacheMode,
|
type MpvCacheMode,
|
||||||
type MpvVoDriver,
|
type MpvVoDriver,
|
||||||
type SegmentSkipMode,
|
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
@@ -48,22 +47,6 @@ import {
|
|||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||||
|
|
||||||
const SEGMENT_SKIP_ROWS: {
|
|
||||||
key:
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview";
|
|
||||||
labelKey: string;
|
|
||||||
}[] = [
|
|
||||||
{ key: "skipIntro", labelKey: "skip_intro" },
|
|
||||||
{ key: "skipOutro", labelKey: "skip_outro" },
|
|
||||||
{ key: "skipRecap", labelKey: "skip_recap" },
|
|
||||||
{ key: "skipCommercial", labelKey: "skip_commercial" },
|
|
||||||
{ key: "skipPreview", labelKey: "skip_preview" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -552,30 +535,6 @@ export default function SettingsTV() {
|
|||||||
);
|
);
|
||||||
}, [inactivityTimeoutOptions, t]);
|
}, [inactivityTimeoutOptions, t]);
|
||||||
|
|
||||||
// Segment skip: same auto/ask/none choice for every segment type.
|
|
||||||
const segmentSkipModeLabel = (mode: SegmentSkipMode) =>
|
|
||||||
t(`home.settings.other.segment_skip_${mode}`);
|
|
||||||
|
|
||||||
const buildSegmentSkipOptions = (
|
|
||||||
current: SegmentSkipMode,
|
|
||||||
): TVOptionItem<SegmentSkipMode>[] => [
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_auto"),
|
|
||||||
value: "auto",
|
|
||||||
selected: current === "auto",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_ask"),
|
|
||||||
value: "ask",
|
|
||||||
selected: current === "ask",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_none"),
|
|
||||||
value: "none",
|
|
||||||
selected: current === "none",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -860,30 +819,6 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v} MB`}
|
formatValue={(v) => `${v} MB`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Segment Skip Section */}
|
|
||||||
<TVSectionHeader
|
|
||||||
title={t("home.settings.other.segment_skip_settings")}
|
|
||||||
/>
|
|
||||||
{SEGMENT_SKIP_ROWS.map((row, index) => {
|
|
||||||
const current = (settings[row.key] ?? "ask") as SegmentSkipMode;
|
|
||||||
const rowLabel = t(`home.settings.other.${row.labelKey}`);
|
|
||||||
return (
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
key={row.key}
|
|
||||||
label={rowLabel}
|
|
||||||
value={segmentSkipModeLabel(current)}
|
|
||||||
isFirst={index === 0}
|
|
||||||
onPress={() =>
|
|
||||||
showOptions({
|
|
||||||
title: rowLabel,
|
|
||||||
options: buildSegmentSkipOptions(current),
|
|
||||||
onSelect: (value) => updateSettings({ [row.key]: value }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Appearance Section */}
|
{/* Appearance Section */}
|
||||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
type SkipSettingKey =
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview";
|
|
||||||
|
|
||||||
const SEGMENTS: ReadonlyArray<{ key: SkipSettingKey; labelKey: string }> = [
|
|
||||||
{ key: "skipIntro", labelKey: "skip_intro" },
|
|
||||||
{ key: "skipOutro", labelKey: "skip_outro" },
|
|
||||||
{ key: "skipRecap", labelKey: "skip_recap" },
|
|
||||||
{ key: "skipCommercial", labelKey: "skip_commercial" },
|
|
||||||
{ key: "skipPreview", labelKey: "skip_preview" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SEGMENT_SKIP_OPTIONS = (
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
): Array<{ label: string; value: SegmentSkipMode }> => [
|
|
||||||
{ label: t("home.settings.other.segment_skip_auto"), value: "auto" },
|
|
||||||
{ label: t("home.settings.other.segment_skip_ask"), value: "ask" },
|
|
||||||
{ label: t("home.settings.other.segment_skip_none"), value: "none" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SegmentSkipPage() {
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: t("home.settings.other.segment_skip_settings"),
|
|
||||||
});
|
|
||||||
}, [navigation, t]);
|
|
||||||
|
|
||||||
const options = useMemo(() => SEGMENT_SKIP_OPTIONS(t), [t]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
{SEGMENTS.map(({ key, labelKey }) => {
|
|
||||||
const current = settings[key];
|
|
||||||
const locked = pluginSettings?.[key]?.locked ?? false;
|
|
||||||
const groups = [
|
|
||||||
{
|
|
||||||
options: options.map((o) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: o.label,
|
|
||||||
value: o.value,
|
|
||||||
selected: o.value === current,
|
|
||||||
disabled: locked,
|
|
||||||
onPress: () => {
|
|
||||||
if (locked) return;
|
|
||||||
updateSettings({ [key]: o.value });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<ListItem
|
|
||||||
key={key}
|
|
||||||
title={t(`home.settings.other.${labelKey}`)}
|
|
||||||
subtitle={t(`home.settings.other.${labelKey}_description`)}
|
|
||||||
disabled={locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={groups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${current}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t(`home.settings.other.${labelKey}`)}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this.replaceAll("_", " ").replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
[source, streamType],
|
[source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSteam = useMemo(
|
const selectedSteam = useMemo(
|
||||||
|
|||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||||
|
/>
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
api,
|
api,
|
||||||
item: library,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[api, library],
|
[library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
|
|||||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
|
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function TabBarIcon({
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
|
}
|
||||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, _setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className='h-1 bg-red-600 w-full' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type PosterProps = {
|
||||||
|
id?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url || !id)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='border border-neutral-900'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParentPoster;
|
||||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -115,6 +115,9 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
|
</Text>
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -253,15 +251,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</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>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,27 +19,10 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export type TVSkipSegmentType =
|
|
||||||
| "intro"
|
|
||||||
| "credits"
|
|
||||||
| "outro"
|
|
||||||
| "recap"
|
|
||||||
| "commercial"
|
|
||||||
| "preview";
|
|
||||||
|
|
||||||
const SEGMENT_LABEL_KEY: Record<TVSkipSegmentType, string> = {
|
|
||||||
intro: "player.skip_intro",
|
|
||||||
credits: "player.skip_credits",
|
|
||||||
outro: "player.skip_outro",
|
|
||||||
recap: "player.skip_recap",
|
|
||||||
commercial: "player.skip_commercial",
|
|
||||||
preview: "player.skip_preview",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TVSkipSegmentCardProps {
|
export interface TVSkipSegmentCardProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
type: TVSkipSegmentType;
|
type: "intro" | "credits";
|
||||||
/** Whether controls are visible - affects card position */
|
/** Whether controls are visible - affects card position */
|
||||||
controlsVisible?: boolean;
|
controlsVisible?: boolean;
|
||||||
/** Callback ref setter for focus guide destination pattern */
|
/** Callback ref setter for focus guide destination pattern */
|
||||||
@@ -89,7 +72,8 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
|||||||
bottom: bottomPosition.value,
|
bottom: bottomPosition.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const labelText = t(SEGMENT_LABEL_KEY[type]);
|
const labelText =
|
||||||
|
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipSegmentButton: boolean;
|
showSkipButton: boolean;
|
||||||
skipSegmentButtonText: string;
|
showSkipCreditButton: boolean;
|
||||||
showSkipOutroButton: boolean;
|
|
||||||
skipOutroButtonText: string;
|
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
onSkipSegment: () => void;
|
skipIntro: () => void;
|
||||||
onSkipOutro: () => void;
|
skipCredit: () => void;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
@@ -88,13 +86,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipSegmentButton,
|
showSkipButton,
|
||||||
skipSegmentButtonText,
|
showSkipCreditButton,
|
||||||
showSkipOutroButton,
|
|
||||||
skipOutroButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
onSkipSegment,
|
skipIntro,
|
||||||
onSkipOutro,
|
skipCredit,
|
||||||
nextItem,
|
nextItem,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
@@ -185,18 +181,19 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipSegmentButton}
|
showButton={showSkipButton}
|
||||||
onPress={onSkipSegment}
|
onPress={skipIntro}
|
||||||
buttonText={skipSegmentButtonText}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Outro button defers to "Next Episode" when credits run to the
|
{/* Smart Skip Credits behavior:
|
||||||
video end and a next episode exists. */}
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
|
- Show "Next Episode" if credits extend to video end AND next episode exists */}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={
|
showButton={
|
||||||
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={onSkipOutro}
|
onPress={skipCredit}
|
||||||
buttonText={skipOutroButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
@@ -207,7 +204,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
!nextItem
|
!nextItem
|
||||||
? false
|
? false
|
||||||
: // Show during credits if no content after, OR near end of video
|
: // Show during credits if no content after, OR near end of video
|
||||||
(showSkipOutroButton && !hasContentAfterCredits) ||
|
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||||
remainingTime < 10000
|
remainingTime < 10000
|
||||||
}
|
}
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
onFinish={handleNextEpisodeAutoPlay}
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -24,17 +16,17 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
@@ -51,9 +43,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||||
|
|
||||||
// No-op function to avoid creating new references on every render
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -122,24 +111,6 @@ export const Controls: FC<Props> = ({
|
|||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = 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 { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
const { previousItem, nextItem } = usePlaybackManager({
|
||||||
item,
|
item,
|
||||||
@@ -345,140 +316,27 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Fetch all segments for the current item
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
useCreditSkipper(
|
||||||
|
item.Id!,
|
||||||
// Segment hook deals in seconds; player API in ms. The 200ms delayed play()
|
currentTime,
|
||||||
// is a workaround: some seeks otherwise resume from the pre-seek position.
|
seek,
|
||||||
const seekMs = useCallback(
|
play,
|
||||||
(timeInSeconds: number) => {
|
offline,
|
||||||
if (playTimeoutRef.current) {
|
api,
|
||||||
clearTimeout(playTimeoutRef.current);
|
downloadedFiles,
|
||||||
}
|
maxMs,
|
||||||
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(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -712,13 +570,11 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipSegmentButton={showSkipSegmentButton}
|
showSkipButton={showSkipButton}
|
||||||
skipSegmentButtonText={skipSegmentButtonText}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
showSkipOutroButton={showSkipOutroButton}
|
|
||||||
skipOutroButtonText={skipOutroButtonText}
|
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
onSkipSegment={onSkipSegment}
|
skipIntro={skipIntro}
|
||||||
onSkipOutro={onSkipOutro}
|
skipCredit={skipCredit}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ import {
|
|||||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
@@ -50,14 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
import {
|
|
||||||
formatTimeString,
|
|
||||||
msToSeconds,
|
|
||||||
msToTicks,
|
|
||||||
secondsToMs,
|
|
||||||
ticksToMs,
|
|
||||||
} from "@/utils/time";
|
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
||||||
@@ -105,9 +99,6 @@ interface Props {
|
|||||||
const TV_SEEKBAR_HEIGHT = 14;
|
const TV_SEEKBAR_HEIGHT = 14;
|
||||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
||||||
|
|
||||||
// Stable no-op so the generic skip card keeps a constant onPress when idle.
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
// Trickplay bubble positioning constants
|
// Trickplay bubble positioning constants
|
||||||
const TV_TRICKPLAY_SCALE = 2;
|
const TV_TRICKPLAY_SCALE = 2;
|
||||||
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
|
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
|
||||||
@@ -436,139 +427,30 @@ export const Controls: FC<Props> = ({
|
|||||||
seek,
|
seek,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Segment skipping (intro + outro/credits) via the unified hook.
|
// Skip intro/credits hooks
|
||||||
|
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
_play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const maxSeconds = msToSeconds(maxMs);
|
useCreditSkipper(
|
||||||
|
item.Id!,
|
||||||
// useSegmentSkipper deals in seconds; the player seek expects ms. The 200ms
|
currentTime,
|
||||||
// delayed play() mirrors the mobile controls: some seeks otherwise resume
|
seek,
|
||||||
// from the pre-seek position.
|
_play,
|
||||||
const playSegmentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
offline,
|
||||||
null,
|
api,
|
||||||
);
|
downloadedFiles,
|
||||||
useEffect(() => {
|
max.value,
|
||||||
return () => {
|
);
|
||||||
if (playSegmentTimeoutRef.current) {
|
|
||||||
clearTimeout(playSegmentTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const seekSeconds = useCallback(
|
|
||||||
(timeInSeconds: number) => {
|
|
||||||
if (playSegmentTimeoutRef.current) {
|
|
||||||
clearTimeout(playSegmentTimeoutRef.current);
|
|
||||||
}
|
|
||||||
seek(secondsToMs(timeInSeconds));
|
|
||||||
playSegmentTimeoutRef.current = setTimeout(() => {
|
|
||||||
_play();
|
|
||||||
playSegmentTimeoutRef.current = null;
|
|
||||||
}, 200);
|
|
||||||
},
|
|
||||||
[seek, _play],
|
|
||||||
);
|
|
||||||
|
|
||||||
const introSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.introSegments ?? [],
|
|
||||||
segmentType: "Intro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const outroSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.creditSegments ?? [],
|
|
||||||
segmentType: "Outro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
totalDuration: maxSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const recapSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.recapSegments ?? [],
|
|
||||||
segmentType: "Recap",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const commercialSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.commercialSegments ?? [],
|
|
||||||
segmentType: "Commercial",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const previewSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.previewSegments ?? [],
|
|
||||||
segmentType: "Preview",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
|
|
||||||
// The outro keeps its dedicated card (it composes with the Next Episode
|
|
||||||
// countdown); the other four share one generic skip card. Including the outro
|
|
||||||
// here keeps the two cards mutually exclusive.
|
|
||||||
const activeSegment = useMemo(() => {
|
|
||||||
if (commercialSkipper.currentSegment)
|
|
||||||
return {
|
|
||||||
type: "commercial" as const,
|
|
||||||
skipSegment: commercialSkipper.skipSegment,
|
|
||||||
};
|
|
||||||
if (recapSkipper.currentSegment)
|
|
||||||
return { type: "recap" as const, skipSegment: recapSkipper.skipSegment };
|
|
||||||
if (introSkipper.currentSegment)
|
|
||||||
return { type: "intro" as const, skipSegment: introSkipper.skipSegment };
|
|
||||||
if (previewSkipper.currentSegment)
|
|
||||||
return {
|
|
||||||
type: "preview" as const,
|
|
||||||
skipSegment: previewSkipper.skipSegment,
|
|
||||||
};
|
|
||||||
if (outroSkipper.currentSegment)
|
|
||||||
return { type: "outro" as const, skipSegment: outroSkipper.skipSegment };
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
commercialSkipper.currentSegment,
|
|
||||||
commercialSkipper.skipSegment,
|
|
||||||
recapSkipper.currentSegment,
|
|
||||||
recapSkipper.skipSegment,
|
|
||||||
introSkipper.currentSegment,
|
|
||||||
introSkipper.skipSegment,
|
|
||||||
previewSkipper.currentSegment,
|
|
||||||
previewSkipper.skipSegment,
|
|
||||||
outroSkipper.currentSegment,
|
|
||||||
outroSkipper.skipSegment,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isOutroActive = activeSegment?.type === "outro";
|
|
||||||
|
|
||||||
// Generic card (intro/recap/commercial/preview).
|
|
||||||
const showSkipButton = !!activeSegment && !isOutroActive;
|
|
||||||
const skipActiveSegment = activeSegment?.skipSegment ?? noop;
|
|
||||||
const activeSegmentType = isOutroActive
|
|
||||||
? "intro"
|
|
||||||
: (activeSegment?.type ?? "intro");
|
|
||||||
|
|
||||||
// Outro card (composes with the Next Episode countdown).
|
|
||||||
const showSkipCreditButton = isOutroActive;
|
|
||||||
const skipCredit = outroSkipper.skipSegment;
|
|
||||||
const hasContentAfterCredits =
|
|
||||||
outroSkipper.currentSegment && maxSeconds
|
|
||||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Countdown logic
|
// Countdown logic
|
||||||
const isCountdownActive = useMemo(() => {
|
const isCountdownActive = useMemo(() => {
|
||||||
@@ -1244,11 +1126,11 @@ export const Controls: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Generic skip card (intro / recap / commercial / preview) */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={skipActiveSegment}
|
onPress={skipIntro}
|
||||||
type={activeSegmentType}
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
hasTVPreferredFocus={!showControls}
|
hasTVPreferredFocus={!showControls}
|
||||||
|
|||||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
|
{ label: "English", value: "eng" },
|
||||||
|
{ label: "Spanish", value: "spa" },
|
||||||
|
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||||
|
{ label: "Hindi", value: "hin" },
|
||||||
|
{ label: "Arabic", value: "ara" },
|
||||||
|
{ label: "French", value: "fra" },
|
||||||
|
{ label: "Russian", value: "rus" },
|
||||||
|
{ label: "Portuguese", value: "por" },
|
||||||
|
{ label: "Japanese", value: "jpn" },
|
||||||
|
{ label: "German", value: "deu" },
|
||||||
|
{ label: "Italian", value: "ita" },
|
||||||
|
{ label: "Korean", value: "kor" },
|
||||||
|
{ label: "Turkish", value: "tur" },
|
||||||
|
{ label: "Dutch", value: "nld" },
|
||||||
|
{ label: "Polish", value: "pol" },
|
||||||
|
{ label: "Vietnamese", value: "vie" },
|
||||||
|
{ label: "Thai", value: "tha" },
|
||||||
|
{ label: "Indonesian", value: "ind" },
|
||||||
|
{ label: "Greek", value: "ell" },
|
||||||
|
{ label: "Swedish", value: "swe" },
|
||||||
|
{ label: "Danish", value: "dan" },
|
||||||
|
{ label: "Norwegian", value: "nor" },
|
||||||
|
{ label: "Finnish", value: "fin" },
|
||||||
|
{ label: "Czech", value: "ces" },
|
||||||
|
{ label: "Hungarian", value: "hun" },
|
||||||
|
{ label: "Romanian", value: "ron" },
|
||||||
|
{ label: "Ukrainian", value: "ukr" },
|
||||||
|
{ label: "Hebrew", value: "heb" },
|
||||||
|
{ label: "Bengali", value: "ben" },
|
||||||
|
{ label: "Punjabi", value: "pan" },
|
||||||
|
{ label: "Tagalog", value: "tgl" },
|
||||||
|
{ label: "Swahili", value: "swa" },
|
||||||
|
{ label: "Malay", value: "msa" },
|
||||||
|
{ label: "Persian", value: "fas" },
|
||||||
|
{ label: "Urdu", value: "urd" },
|
||||||
|
];
|
||||||
37
hooks/useControlsVisibility.ts
Normal file
37
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const useControlsVisibility = (timeout = 3000) => {
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
opacity.value = 1;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
}, timeout);
|
||||||
|
}, [timeout]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { opacity, showControls, hideControls };
|
||||||
|
};
|
||||||
109
hooks/useCreditSkipper.ts
Normal file
109
hooks/useCreditSkipper.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping credits in a media player.
|
||||||
|
* The player reports time values in milliseconds.
|
||||||
|
*/
|
||||||
|
export const useCreditSkipper = (
|
||||||
|
itemId: string,
|
||||||
|
currentTime: number,
|
||||||
|
seek: (ms: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isOffline = false,
|
||||||
|
api: Api | null = null,
|
||||||
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
|
totalDuration?: number,
|
||||||
|
) => {
|
||||||
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
// Convert ms to seconds for comparison with timestamps
|
||||||
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
|
|
||||||
|
const totalDurationInSeconds =
|
||||||
|
totalDuration != null ? msToSeconds(totalDuration) : undefined;
|
||||||
|
|
||||||
|
// Regular function (not useCallback) to match useIntroSkipper pattern
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: segments } = useSegments(
|
||||||
|
itemId,
|
||||||
|
isOffline,
|
||||||
|
downloadedFiles,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const creditTimestamps = segments?.creditSegments?.[0];
|
||||||
|
|
||||||
|
// Determine if there's content after credits (credits don't extend to video end)
|
||||||
|
// Use a 5-second buffer to account for timing discrepancies
|
||||||
|
const hasContentAfterCredits = (() => {
|
||||||
|
if (
|
||||||
|
!creditTimestamps ||
|
||||||
|
totalDurationInSeconds == null ||
|
||||||
|
!Number.isFinite(totalDurationInSeconds)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const creditsEndToVideoEnd =
|
||||||
|
totalDurationInSeconds - creditTimestamps.endTime;
|
||||||
|
// If credits end more than 5 seconds before video ends, there's content after
|
||||||
|
return creditsEndToVideoEnd > 5;
|
||||||
|
})();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creditTimestamps) {
|
||||||
|
const shouldShow =
|
||||||
|
currentTimeSeconds > creditTimestamps.startTime &&
|
||||||
|
currentTimeSeconds < creditTimestamps.endTime;
|
||||||
|
|
||||||
|
setShowSkipCreditButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
// Reset button state when no credit timestamps exist
|
||||||
|
if (showSkipCreditButton) {
|
||||||
|
setShowSkipCreditButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
|
||||||
|
|
||||||
|
const skipCredit = useCallback(() => {
|
||||||
|
if (!creditTimestamps) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Calculate the target seek position
|
||||||
|
let seekTarget = creditTimestamps.endTime;
|
||||||
|
|
||||||
|
// If we have total duration, ensure we don't seek past the end of the video.
|
||||||
|
// Some media sources report credit end times that exceed the actual video duration,
|
||||||
|
// which causes the player to pause/stop when seeking past the end.
|
||||||
|
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
|
||||||
|
// (next episode countdown, etc.) instead of an abrupt pause.
|
||||||
|
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
|
||||||
|
seekTarget = Math.max(0, totalDurationInSeconds - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappedSeek(seekTarget);
|
||||||
|
setTimeout(() => {
|
||||||
|
play();
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
creditTimestamps,
|
||||||
|
lightHapticFeedback,
|
||||||
|
wrappedSeek,
|
||||||
|
play,
|
||||||
|
totalDurationInSeconds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
|
||||||
|
};
|
||||||
35
hooks/useDownloadedFileOpener.ts
Normal file
35
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export const useDownloadedFileOpener = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
if (!item.Id) {
|
||||||
|
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||||
|
console.error("Attempted to open a file without an ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
|
console.error("Error opening file:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOfflineSettings, setPlayUrl, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { openFile };
|
||||||
|
};
|
||||||
120
hooks/useImageColors.ts
Normal file
120
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import type * as ImageColorsType from "react-native-image-colors";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||||
|
const ImageColors = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||||
|
|
||||||
|
import {
|
||||||
|
adjustToNearBlack,
|
||||||
|
calculateTextColor,
|
||||||
|
isCloseToBlack,
|
||||||
|
itemThemeColorAtom,
|
||||||
|
} from "@/utils/atoms/primaryColor";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
|
*
|
||||||
|
* @param item - The BaseItemDto object representing the item.
|
||||||
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const useImageColors = ({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
|
const source = useMemo(() => {
|
||||||
|
if (!api) return;
|
||||||
|
if (url) return { uri: url };
|
||||||
|
if (item)
|
||||||
|
return getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [api, item, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTv) return;
|
||||||
|
if (disabled) return;
|
||||||
|
if (source?.uri) {
|
||||||
|
const _primary = storage.getString(`${source.uri}-primary`);
|
||||||
|
const _text = storage.getString(`${source.uri}-text`);
|
||||||
|
|
||||||
|
if (_primary && _text) {
|
||||||
|
setPrimaryColor({
|
||||||
|
primary: _primary,
|
||||||
|
text: _text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract colors from the image
|
||||||
|
if (!ImageColors?.getColors) return;
|
||||||
|
|
||||||
|
ImageColors.getColors(source.uri, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: false,
|
||||||
|
})
|
||||||
|
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||||
|
let primary = "#fff";
|
||||||
|
let text = "#000";
|
||||||
|
let backup = "#fff";
|
||||||
|
|
||||||
|
// Select the appropriate color based on the platform
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
primary = colors.dominant;
|
||||||
|
backup = colors.vibrant;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
primary = colors.detail;
|
||||||
|
backup = colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the primary color if it's too close to black
|
||||||
|
if (primary && isCloseToBlack(primary)) {
|
||||||
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
|
setPrimaryColor({
|
||||||
|
primary,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the colors in storage
|
||||||
|
if (source.uri && primary) {
|
||||||
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
|
storage.set(`${source.uri}-text`, text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||||
|
|
||||||
|
if (isTv) return;
|
||||||
|
};
|
||||||
68
hooks/useIntroSkipper.ts
Normal file
68
hooks/useIntroSkipper.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping intros in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
|
*
|
||||||
|
* @param {number} currentTime - The current playback time in milliseconds.
|
||||||
|
*/
|
||||||
|
export const useIntroSkipper = (
|
||||||
|
itemId: string,
|
||||||
|
currentTime: number,
|
||||||
|
seek: (ms: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isOffline = false,
|
||||||
|
api: Api | null = null,
|
||||||
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
|
) => {
|
||||||
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
|
// Convert ms to seconds for comparison with timestamps
|
||||||
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: segments } = useSegments(
|
||||||
|
itemId,
|
||||||
|
isOffline,
|
||||||
|
downloadedFiles,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introTimestamps) {
|
||||||
|
const shouldShow =
|
||||||
|
currentTimeSeconds > introTimestamps.startTime &&
|
||||||
|
currentTimeSeconds < introTimestamps.endTime;
|
||||||
|
|
||||||
|
setShowSkipButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
if (showSkipButton) {
|
||||||
|
setShowSkipButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||||
|
|
||||||
|
const skipIntro = useCallback(() => {
|
||||||
|
if (!introTimestamps) return;
|
||||||
|
try {
|
||||||
|
lightHapticFeedback();
|
||||||
|
wrappedSeek(introTimestamps.endTime);
|
||||||
|
setTimeout(() => {
|
||||||
|
play();
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[INTRO_SKIPPER] Error skipping intro", error);
|
||||||
|
}
|
||||||
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
|
return { showSkipButton, skipIntro };
|
||||||
|
};
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
|
||||||
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useHaptic } from "./useHaptic";
|
|
||||||
|
|
||||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
|
||||||
|
|
||||||
const SEGMENT_TO_SETTING: Record<
|
|
||||||
SegmentType,
|
|
||||||
"skipIntro" | "skipOutro" | "skipRecap" | "skipCommercial" | "skipPreview"
|
|
||||||
> = {
|
|
||||||
Intro: "skipIntro",
|
|
||||||
Outro: "skipOutro",
|
|
||||||
Recap: "skipRecap",
|
|
||||||
Commercial: "skipCommercial",
|
|
||||||
Preview: "skipPreview",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseSegmentSkipperProps {
|
|
||||||
segments: MediaTimeSegment[];
|
|
||||||
segmentType: SegmentType;
|
|
||||||
currentTime: number;
|
|
||||||
totalDuration?: number;
|
|
||||||
seek: (time: number) => void;
|
|
||||||
isPaused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSegmentSkipperReturn {
|
|
||||||
currentSegment: MediaTimeSegment | null;
|
|
||||||
skipSegment: (useHaptics?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
|
||||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
|
||||||
*/
|
|
||||||
export const useSegmentSkipper = ({
|
|
||||||
segments,
|
|
||||||
segmentType,
|
|
||||||
currentTime,
|
|
||||||
totalDuration,
|
|
||||||
seek,
|
|
||||||
isPaused,
|
|
||||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const haptic = useHaptic();
|
|
||||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const skipMode: SegmentSkipMode =
|
|
||||||
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
|
|
||||||
|
|
||||||
const currentSegment = useMemo(
|
|
||||||
() =>
|
|
||||||
segments.find(
|
|
||||||
(s) => currentTime >= s.startTime && currentTime < s.endTime,
|
|
||||||
) ?? null,
|
|
||||||
[segments, currentTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refs let the auto-skip effect avoid re-running when skipSegment/haptic
|
|
||||||
// identities change (haptic is unstable when disabled).
|
|
||||||
const seekRef = useRef(seek);
|
|
||||||
const hapticRef = useRef(haptic);
|
|
||||||
useEffect(() => {
|
|
||||||
seekRef.current = seek;
|
|
||||||
hapticRef.current = haptic;
|
|
||||||
});
|
|
||||||
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
(useHaptics = true) => {
|
|
||||||
if (!currentSegment || skipMode === "none") return;
|
|
||||||
|
|
||||||
// Outro endTime sometimes exceeds the actual file duration. Keep a 2s
|
|
||||||
// buffer so the player's natural end-of-video flow (next-episode
|
|
||||||
// countdown, etc.) still fires instead of stalling at the exact end.
|
|
||||||
let target = currentSegment.endTime;
|
|
||||||
if (
|
|
||||||
segmentType === "Outro" &&
|
|
||||||
totalDuration != null &&
|
|
||||||
Number.isFinite(totalDuration) &&
|
|
||||||
target >= totalDuration
|
|
||||||
) {
|
|
||||||
target = Math.max(0, totalDuration - 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
seekRef.current(target);
|
|
||||||
|
|
||||||
if (useHaptics) hapticRef.current();
|
|
||||||
},
|
|
||||||
[currentSegment, segmentType, totalDuration, skipMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (skipMode !== "auto" || isPaused || !currentSegment) {
|
|
||||||
if (!currentSegment) autoSkipTriggeredRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const segmentId = `${currentSegment.startTime}-${currentSegment.endTime}`;
|
|
||||||
if (autoSkipTriggeredRef.current === segmentId) return;
|
|
||||||
autoSkipTriggeredRef.current = segmentId;
|
|
||||||
skipSegment(false);
|
|
||||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
|
||||||
skipSegment,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -53,6 +53,7 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
|||||||
const fetchSSID = useCallback(async () => {
|
const fetchSSID = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
const result = await getSSID();
|
const result = await getSSID();
|
||||||
|
console.log("[WiFi Debug] Native module SSID:", result);
|
||||||
setSSID(result);
|
setSSID(result);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const WifiSsidModule =
|
|||||||
*/
|
*/
|
||||||
export async function getSSID(): Promise<string | null> {
|
export async function getSSID(): Promise<string | null> {
|
||||||
if (!WifiSsidModule) {
|
if (!WifiSsidModule) {
|
||||||
|
console.log("[WifiSsid] Module not available on this platform");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,12 +142,31 @@ export function useDownloadEventHandlers({
|
|||||||
} else {
|
} else {
|
||||||
// Transcoding - estimate from bitrate
|
// Transcoding - estimate from bitrate
|
||||||
const process = processes.find((p) => p.id === processId);
|
const process = processes.find((p) => p.id === processId);
|
||||||
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
|
console.log(
|
||||||
const { estimateDownloadSize } = require("@/utils/download");
|
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
|
||||||
estimatedTotalBytes = estimateDownloadSize(
|
process ? "yes" : "no",
|
||||||
process.maxBitrate.value,
|
);
|
||||||
process.item.RunTimeTicks,
|
if (process) {
|
||||||
);
|
console.log(`[DPL] Process bitrate:`, {
|
||||||
|
key: process.maxBitrate.key,
|
||||||
|
value: process.maxBitrate.value,
|
||||||
|
runTimeTicks: process.item.RunTimeTicks,
|
||||||
|
});
|
||||||
|
if (process.maxBitrate.value && process.item.RunTimeTicks) {
|
||||||
|
const { estimateDownloadSize } = require("@/utils/download");
|
||||||
|
estimatedTotalBytes = estimateDownloadSize(
|
||||||
|
process.maxBitrate.value,
|
||||||
|
process.item.RunTimeTicks,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[DPL] Calculated estimatedTotalBytes:`,
|
||||||
|
estimatedTotalBytes,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||||
export interface DownloadedItem {
|
export interface DownloadedItem {
|
||||||
/** The Jellyfin item DTO. */
|
/** The Jellyfin item DTO. */
|
||||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** The credit segments for the item. */
|
/** The credit segments for the item. */
|
||||||
creditSegments?: MediaTimeSegment[];
|
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. */
|
/** The user data for the item. */
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||||
creditSegments?: MediaTimeSegment[];
|
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 */
|
/** The audio stream index selected for this download */
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
/** The subtitle stream index selected for this download */
|
/** The subtitle stream index selected for this download */
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
@@ -28,20 +28,6 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
|
|||||||
["episodes"],
|
["episodes"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Query keys that depend on per-user playback state (resume position, played
|
|
||||||
// status, favorites) and should be refreshed when the server reports a
|
|
||||||
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
|
|
||||||
// episode does not pointlessly refetch "recently added" or suggestions.
|
|
||||||
const USER_DATA_CHANGE_QUERY_KEYS = [
|
|
||||||
["home", "continueAndNextUp"],
|
|
||||||
["home", "resumeItems"],
|
|
||||||
["home", "nextUp-all"],
|
|
||||||
["home", "heroItems"],
|
|
||||||
["resumeItems"],
|
|
||||||
["nextUp-all"],
|
|
||||||
["nextUp"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
MessageType: string;
|
MessageType: string;
|
||||||
Data: any;
|
Data: any;
|
||||||
@@ -52,30 +38,10 @@ interface WebSocketProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler invoked for every message of a given `MessageType`. Receives the
|
|
||||||
* message `Data` payload and the full message.
|
|
||||||
*/
|
|
||||||
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
|
|
||||||
|
|
||||||
interface WebSocketContextType {
|
interface WebSocketContextType {
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/**
|
|
||||||
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
|
|
||||||
* message, so bursts arriving in the same tick are coalesced and lost. Kept
|
|
||||||
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
|
|
||||||
*/
|
|
||||||
lastMessage: WebSocketMessage | null;
|
lastMessage: WebSocketMessage | null;
|
||||||
/**
|
|
||||||
* Subscribe to a given message type. The handler is called synchronously for
|
|
||||||
* every matching message (no coalescing, unlike `lastMessage`). Returns an
|
|
||||||
* unsubscribe function to call on cleanup.
|
|
||||||
*/
|
|
||||||
subscribe: (
|
|
||||||
messageType: string,
|
|
||||||
handler: WebSocketMessageHandler,
|
|
||||||
) => () => void;
|
|
||||||
sendMessage: (message: any) => void;
|
sendMessage: (message: any) => void;
|
||||||
clearLastMessage: () => void;
|
clearLastMessage: () => void;
|
||||||
}
|
}
|
||||||
@@ -88,6 +54,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
return getOrSetDeviceId();
|
return getOrSetDeviceId();
|
||||||
@@ -96,76 +63,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const userDataChangeDebounceRef = useRef<ReturnType<
|
|
||||||
typeof setTimeout
|
|
||||||
> | null>(null);
|
|
||||||
// Handle for the onerror backoff timer. Tracked so a reconnect triggered by
|
|
||||||
// another path (foreground, network reconnect, effect re-run) can cancel a
|
|
||||||
// pending one — an untracked timer would later open a second socket.
|
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
|
|
||||||
// subscribing/dispatching never triggers a re-render.
|
|
||||||
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
|
|
||||||
new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscribe = useCallback(
|
|
||||||
(messageType: string, handler: WebSocketMessageHandler) => {
|
|
||||||
const listeners = listenersRef.current;
|
|
||||||
let handlers = listeners.get(messageType);
|
|
||||||
if (!handlers) {
|
|
||||||
handlers = new Set();
|
|
||||||
listeners.set(messageType, handlers);
|
|
||||||
}
|
|
||||||
handlers.add(handler);
|
|
||||||
return () => {
|
|
||||||
handlers?.delete(handler);
|
|
||||||
// Only drop the map entry if it still points at THIS set. After an
|
|
||||||
// unsubscribe + re-subscribe for the same type, a stale second call to
|
|
||||||
// this cleanup would otherwise delete the new subscribers' set and
|
|
||||||
// silently stop delivering their messages.
|
|
||||||
if (
|
|
||||||
handlers &&
|
|
||||||
handlers.size === 0 &&
|
|
||||||
listeners.get(messageType) === handlers
|
|
||||||
) {
|
|
||||||
listeners.delete(messageType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dispatchMessage = useCallback((message: WebSocketMessage) => {
|
|
||||||
const handlers = listenersRef.current.get(message.MessageType);
|
|
||||||
if (!handlers || handlers.size === 0) return;
|
|
||||||
// Copy to tolerate handlers that unsubscribe during dispatch.
|
|
||||||
for (const handler of [...handlers]) {
|
|
||||||
// Isolate each handler so one throwing subscriber can't abort the rest
|
|
||||||
// (and isn't misreported as a parse failure by the outer onmessage catch).
|
|
||||||
try {
|
|
||||||
handler(message.Data, message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error handling WebSocket message type "${message.MessageType}":`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
// Cancel any reconnect queued by a previous onerror before opening a new
|
|
||||||
// socket, so we never end up with two live sockets — each would double the
|
|
||||||
// message fan-out and double-invalidate queries.
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,10 +85,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
newWebSocket.onopen = () => {
|
newWebSocket.onopen = () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
keepAliveInterval = setInterval(() => {
|
keepAliveInterval = setInterval(() => {
|
||||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
@@ -201,15 +96,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
// Don't log errors - this is expected when offline or server unreachable
|
// Don't log errors - this is expected when offline or server unreachable
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
|
||||||
// Replace any still-pending reconnect so only one is ever queued; the
|
|
||||||
// previously untracked handle could leak and open a second socket.
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
setTimeout(() => {
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}, reconnectDelay);
|
}, reconnectDelay);
|
||||||
}
|
}
|
||||||
@@ -224,10 +113,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
newWebSocket.onmessage = (e) => {
|
newWebSocket.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(e.data);
|
const message = JSON.parse(e.data);
|
||||||
// Legacy single-slot state, still consumed by useWebsockets.
|
setLastMessage(message); // Store the last message in context
|
||||||
setLastMessage(message);
|
|
||||||
// Pub/sub: deliver to every subscriber without coalescing.
|
|
||||||
dispatchMessage(message);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing WebSocket message:", error);
|
console.error("Error parsing WebSocket message:", error);
|
||||||
}
|
}
|
||||||
@@ -238,13 +124,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
newWebSocket.close();
|
newWebSocket.close();
|
||||||
};
|
};
|
||||||
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
|
}, [api, deviceId, isNetworkConnected]);
|
||||||
|
|
||||||
const handleLibraryChanged = useCallback(
|
const handleLibraryChanged = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
@@ -275,80 +157,47 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUserDataChanged = useCallback(
|
useEffect(() => {
|
||||||
(data: any) => {
|
if (!lastMessage) {
|
||||||
// Jellyfin sends UserDataChanged when playback position, played status
|
return;
|
||||||
// or favorites change (e.g. finishing an episode). Only the
|
}
|
||||||
// progression-based home sections care about it.
|
if (lastMessage.MessageType === "Play") {
|
||||||
if (!((data?.UserDataList?.length ?? 0) > 0)) {
|
handlePlayCommand(lastMessage.Data);
|
||||||
return;
|
} else if (lastMessage.MessageType === "LibraryChanged") {
|
||||||
}
|
handleLibraryChanged(lastMessage.Data);
|
||||||
|
}
|
||||||
// Finishing an item can emit several UserDataChanged messages, so
|
}, [lastMessage, router, handleLibraryChanged]);
|
||||||
// debounce to invalidate the affected sections only once.
|
|
||||||
if (userDataChangeDebounceRef.current) {
|
|
||||||
clearTimeout(userDataChangeDebounceRef.current);
|
|
||||||
}
|
|
||||||
userDataChangeDebounceRef.current = setTimeout(() => {
|
|
||||||
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [...queryKey] });
|
|
||||||
}
|
|
||||||
}, 800);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh library-dependent queries when the server reports a change.
|
|
||||||
useEffect(
|
|
||||||
() => subscribe("LibraryChanged", handleLibraryChanged),
|
|
||||||
[subscribe, handleLibraryChanged],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh "Continue Watching" / "Next Up" when playback state changes.
|
|
||||||
useEffect(
|
|
||||||
() => subscribe("UserDataChanged", handleUserDataChanged),
|
|
||||||
[subscribe, handleUserDataChanged],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (libraryChangeDebounceRef.current) {
|
if (libraryChangeDebounceRef.current) {
|
||||||
clearTimeout(libraryChangeDebounceRef.current);
|
clearTimeout(libraryChangeDebounceRef.current);
|
||||||
}
|
}
|
||||||
if (userDataChangeDebounceRef.current) {
|
|
||||||
clearTimeout(userDataChangeDebounceRef.current);
|
|
||||||
}
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlayCommand = useCallback((data: any) => {
|
const handlePlayCommand = useCallback(
|
||||||
if (!data?.ItemIds?.length) {
|
(data: any) => {
|
||||||
return;
|
if (!data?.ItemIds?.length) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const itemId = data.ItemIds[0];
|
const itemId = data.ItemIds[0];
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/player/direct-player",
|
pathname: "/(auth)/player/direct-player",
|
||||||
params: {
|
params: {
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
playCommand: data.PlayCommand || "PlayNow",
|
playCommand: data.PlayCommand || "PlayNow",
|
||||||
audioIndex: data.AudioStreamIndex?.toString(),
|
audioIndex: data.AudioStreamIndex?.toString(),
|
||||||
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||||
mediaSourceId: data.MediaSourceId || "",
|
mediaSourceId: data.MediaSourceId || "",
|
||||||
bitrateValue: "",
|
bitrateValue: "",
|
||||||
offline: "false",
|
offline: "false",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[router],
|
||||||
// Server-initiated "Play me this item" remote command.
|
|
||||||
useEffect(
|
|
||||||
() => subscribe("Play", handlePlayCommand),
|
|
||||||
[subscribe, handlePlayCommand],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -418,14 +267,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider
|
<WebSocketContext.Provider
|
||||||
value={{
|
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||||
ws,
|
|
||||||
isConnected,
|
|
||||||
lastMessage,
|
|
||||||
subscribe,
|
|
||||||
sendMessage,
|
|
||||||
clearLastMessage,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Populates the "Streamyfin Version" dropdown in the issue report form with the
|
|
||||||
* latest GitHub releases. Run by the "Update Issue Form Versions" workflow on
|
|
||||||
* release events + a weekly cron (and manually via workflow_dispatch).
|
|
||||||
*
|
|
||||||
* Source: published, non-draft, non-prerelease GitHub releases, newest first.
|
|
||||||
* Non-version sentinels (e.g. "older", "TestFlight/Development build") are
|
|
||||||
* preserved at the end of the list.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun scripts/update-issue-form.mjs # rewrite the form in place
|
|
||||||
* ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs
|
|
||||||
* bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write
|
|
||||||
*
|
|
||||||
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import {
|
|
||||||
appendFileSync,
|
|
||||||
readFileSync as read,
|
|
||||||
writeFileSync as write,
|
|
||||||
} from "node:fs";
|
|
||||||
|
|
||||||
const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml";
|
|
||||||
const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate
|
|
||||||
const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10);
|
|
||||||
const LIMIT =
|
|
||||||
Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
|
|
||||||
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
|
||||||
const DRY = process.argv.includes("--dry-run");
|
|
||||||
|
|
||||||
// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1".
|
|
||||||
const isVersion = (s) => /^\d+\.\d+/.test(s.trim());
|
|
||||||
|
|
||||||
// 1. Fetch the latest published releases (newest first) — drafts and prereleases
|
|
||||||
// aren't a full release users run, so they don't belong in the dropdown.
|
|
||||||
const raw = execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"release",
|
|
||||||
"list",
|
|
||||||
"--repo",
|
|
||||||
REPO,
|
|
||||||
"--exclude-drafts",
|
|
||||||
"--exclude-pre-releases",
|
|
||||||
"--limit",
|
|
||||||
String(LIMIT),
|
|
||||||
"--json",
|
|
||||||
"tagName",
|
|
||||||
"--jq",
|
|
||||||
".[].tagName",
|
|
||||||
],
|
|
||||||
// Bounded timeout so a stuck gh process fails the job fast instead of
|
|
||||||
// holding the workflow open until the job-level timeout.
|
|
||||||
{ encoding: "utf8", timeout: 30_000 },
|
|
||||||
);
|
|
||||||
const seen = new Set();
|
|
||||||
const versions = [];
|
|
||||||
for (const tag of raw.split("\n")) {
|
|
||||||
if (!tag) continue;
|
|
||||||
const ver = tag.trim().replace(/^v/, "");
|
|
||||||
if (!isVersion(ver) || seen.has(ver)) continue;
|
|
||||||
seen.add(ver);
|
|
||||||
versions.push(ver);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!versions.length) {
|
|
||||||
console.error("No release versions found — leaving the form untouched.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. rewrite the dropdown options, preserving non-version sentinels
|
|
||||||
// (e.g. "older", "TestFlight/Development build") at the end of the list.
|
|
||||||
const lines = read(FORM, "utf8").split("\n");
|
|
||||||
const idIdx = lines.findIndex((l) =>
|
|
||||||
l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)),
|
|
||||||
);
|
|
||||||
if (idIdx === -1)
|
|
||||||
throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`);
|
|
||||||
const optIdx = lines.findIndex(
|
|
||||||
(l, i) => i > idIdx && /^\s*options:\s*$/.test(l),
|
|
||||||
);
|
|
||||||
if (optIdx === -1)
|
|
||||||
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
|
|
||||||
|
|
||||||
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
|
|
||||||
let end = optIdx + 1;
|
|
||||||
const sentinels = [];
|
|
||||||
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
|
|
||||||
const val = lines[end].replace(/^\s*-\s+/, "");
|
|
||||||
if (!isVersion(val)) sentinels.push(val);
|
|
||||||
end++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOptions = [...versions, ...sentinels].map(
|
|
||||||
(v) => `${itemIndent}- ${v}`,
|
|
||||||
);
|
|
||||||
const updated = [
|
|
||||||
...lines.slice(0, optIdx + 1),
|
|
||||||
...newOptions,
|
|
||||||
...lines.slice(end),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`,
|
|
||||||
);
|
|
||||||
if (DRY) {
|
|
||||||
console.log("--dry-run: not writing.");
|
|
||||||
} else {
|
|
||||||
write(FORM, updated);
|
|
||||||
console.log(`Updated ${FORM}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose the resulting list for the workflow (PR description).
|
|
||||||
if (process.env.GITHUB_OUTPUT) {
|
|
||||||
appendFileSync(
|
|
||||||
process.env.GITHUB_OUTPUT,
|
|
||||||
`versions=${versions.join(", ")}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:",
|
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:",
|
||||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||||
"downloads_feature_title": "Downloads",
|
"downloads_feature_title": "Downloads",
|
||||||
"downloads_feature_description": "Download movies and series to watch offline.",
|
"downloads_feature_description": "Download movies and series to watch offline. Use either the default method or install the optimize server to download files in the background.",
|
||||||
"chromecast_feature_description": "Cast movies and series to your Chromecast devices.",
|
"chromecast_feature_description": "Cast movies and series to your Chromecast devices.",
|
||||||
"centralised_settings_plugin_title": "Centralised Settings plugin",
|
"centralised_settings_plugin_title": "Centralised Settings plugin",
|
||||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||||
@@ -304,21 +304,6 @@
|
|||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
"auto_play_next_episode": "Auto-play next episode",
|
"auto_play_next_episode": "Auto-play next episode",
|
||||||
"max_auto_play_episode_count": "Max auto-play episode count",
|
"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"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
@@ -335,6 +320,7 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -446,6 +432,10 @@
|
|||||||
"4_hours": "4 hours",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 hours"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"sessions_title": "Sessions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -644,10 +634,6 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"skip_intro": "Skip intro",
|
"skip_intro": "Skip intro",
|
||||||
"skip_credits": "Skip credits",
|
"skip_credits": "Skip credits",
|
||||||
"skip_outro": "Skip outro",
|
|
||||||
"skip_recap": "Skip recap",
|
|
||||||
"skip_commercial": "Skip commercial",
|
|
||||||
"skip_preview": "Skip preview",
|
|
||||||
"stopPlayback": "Stop playback",
|
"stopPlayback": "Stop playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
|||||||
@@ -183,9 +183,6 @@ export enum TVTypographyScale {
|
|||||||
ExtraLarge = "extraLarge",
|
ExtraLarge = "extraLarge",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// Audio transcoding mode - controls how surround audio is handled
|
// Audio transcoding mode - controls how surround audio is handled
|
||||||
// This controls server-side transcoding behavior for audio streams.
|
// This controls server-side transcoding behavior for audio streams.
|
||||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||||
@@ -249,12 +246,6 @@ export type Settings = {
|
|||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
// Media segment skip preferences
|
|
||||||
skipIntro: SegmentSkipMode;
|
|
||||||
skipOutro: SegmentSkipMode;
|
|
||||||
skipRecap: SegmentSkipMode;
|
|
||||||
skipCommercial: SegmentSkipMode;
|
|
||||||
skipPreview: SegmentSkipMode;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -358,12 +349,6 @@ export const defaultValues: Settings = {
|
|||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
// Media segment skip defaults
|
|
||||||
skipIntro: "ask",
|
|
||||||
skipOutro: "ask",
|
|
||||||
skipRecap: "ask",
|
|
||||||
skipCommercial: "ask",
|
|
||||||
skipPreview: "ask",
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
|
|||||||
18
utils/bToMb.ts
Normal file
18
utils/bToMb.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Convert bits to megabits or gigabits
|
||||||
|
*
|
||||||
|
* Return nice looking string
|
||||||
|
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||||
|
if (!bits) return "0MB";
|
||||||
|
|
||||||
|
const megabits = bits / 1000000;
|
||||||
|
|
||||||
|
if (megabits < 1000) {
|
||||||
|
return `${Math.round(megabits)}MB`;
|
||||||
|
}
|
||||||
|
const gigabits = megabits / 1000;
|
||||||
|
return `${gigabits.toFixed(1)}GB`;
|
||||||
|
}
|
||||||
47
utils/collectionTypeToItemType.ts
Normal file
47
utils/collectionTypeToItemType.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||||
|
*
|
||||||
|
* CollectionTypes
|
||||||
|
* readonly Unknown: "unknown";
|
||||||
|
readonly Movies: "movies";
|
||||||
|
readonly Tvshows: "tvshows";
|
||||||
|
readonly Trailers: "trailers";
|
||||||
|
readonly Homevideos: "homevideos";
|
||||||
|
readonly Boxsets: "boxsets";
|
||||||
|
readonly Books: "books";
|
||||||
|
readonly Photos: "photos";
|
||||||
|
readonly Livetv: "livetv";
|
||||||
|
readonly Playlists: "playlists";
|
||||||
|
readonly Folders: "folders";
|
||||||
|
*/
|
||||||
|
export const colletionTypeToItemType = (
|
||||||
|
collectionType?: CollectionType | null,
|
||||||
|
): BaseItemKind | undefined => {
|
||||||
|
if (!collectionType) return undefined;
|
||||||
|
|
||||||
|
switch (collectionType) {
|
||||||
|
case CollectionType.Movies:
|
||||||
|
return BaseItemKind.Movie;
|
||||||
|
case CollectionType.Tvshows:
|
||||||
|
return BaseItemKind.Series;
|
||||||
|
case CollectionType.Homevideos:
|
||||||
|
return BaseItemKind.Video;
|
||||||
|
case CollectionType.Books:
|
||||||
|
return BaseItemKind.Book;
|
||||||
|
case CollectionType.Playlists:
|
||||||
|
return BaseItemKind.Playlist;
|
||||||
|
case CollectionType.Folders:
|
||||||
|
return BaseItemKind.Folder;
|
||||||
|
case CollectionType.Photos:
|
||||||
|
return BaseItemKind.Photo;
|
||||||
|
case CollectionType.Trailers:
|
||||||
|
return BaseItemKind.Trailer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
uri: string;
|
||||||
|
language: string;
|
||||||
|
default: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
autoSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseM3U8ForSubtitles(
|
||||||
|
url: string,
|
||||||
|
): Promise<SubtitleTrack[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { responseType: "text" });
|
||||||
|
const lines = response.data.split(/\r?\n/);
|
||||||
|
const subtitleTracks: SubtitleTrack[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||||
|
const attributes = parseAttributes(line);
|
||||||
|
const track: SubtitleTrack = {
|
||||||
|
index: index++,
|
||||||
|
name: attributes.NAME || "",
|
||||||
|
uri: attributes.URI || "",
|
||||||
|
language: attributes.LANGUAGE || "",
|
||||||
|
default: attributes.DEFAULT === "YES",
|
||||||
|
forced: attributes.FORCED === "YES",
|
||||||
|
autoSelect: attributes.AUTOSELECT === "YES",
|
||||||
|
};
|
||||||
|
subtitleTracks.push(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return subtitleTracks;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributes(line: string): { [key: string]: string } {
|
||||||
|
const attributes: { [key: string]: string } = {};
|
||||||
|
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
|
||||||
|
for (const match of line.matchAll(regex)) {
|
||||||
|
const key = match[1];
|
||||||
|
const value = match[2] ?? match[3]; // quoted or unquoted
|
||||||
|
attributes[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
56
utils/jellyfin/session/capabilities.ts
Normal file
56
utils/jellyfin/session/capabilities.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import type { Settings } from "../../atoms/settings";
|
||||||
|
import { generateDeviceProfile } from "../../profiles/native";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface PostCapabilitiesParams {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
itemId: string | null | undefined;
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
deviceProfile: Settings["deviceProfile"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a media item as not played for a specific user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for marking an item as not played
|
||||||
|
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const postCapabilities = async ({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||||
|
if (!api || !itemId || !sessionId) {
|
||||||
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = api.axiosInstance.post(
|
||||||
|
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||||
|
{
|
||||||
|
playableMediaTypes: ["Audio", "Video"],
|
||||||
|
supportedCommands: [
|
||||||
|
"PlayState",
|
||||||
|
"Play",
|
||||||
|
"ToggleFullscreen",
|
||||||
|
"DisplayMessage",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
"SetVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
id: sessionId,
|
||||||
|
DeviceProfile: generateDeviceProfile(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return d;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new Error("Failed to mark as not played");
|
||||||
|
}
|
||||||
|
};
|
||||||
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface NextUpParams {
|
||||||
|
itemId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
api?: Api | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next up episodes for a series or all series for a user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for fetching next up episodes
|
||||||
|
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||||
|
*/
|
||||||
|
export const nextUp = async ({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
api,
|
||||||
|
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||||
|
if (!userId || !api) {
|
||||||
|
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||||
|
`${api.basePath}/Shows/NextUp`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
SeriesId: itemId || undefined,
|
||||||
|
UserId: userId,
|
||||||
|
Fields: "MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.Items;
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
34
utils/jellyfin/user-library/getItemById.ts
Normal file
34
utils/jellyfin/user-library/getItemById.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an item by its ID from the API.
|
||||||
|
*
|
||||||
|
* @param api - The Jellyfin API instance.
|
||||||
|
* @param itemId - The ID of the item to retrieve.
|
||||||
|
* @returns The item object or undefined if no item matches the ID.
|
||||||
|
*/
|
||||||
|
export const getItemById = async (
|
||||||
|
api?: Api | null | undefined,
|
||||||
|
itemId?: string | null | undefined,
|
||||||
|
): Promise<BaseItemDto | undefined> => {
|
||||||
|
if (!api || !itemId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||||
|
|
||||||
|
const item = itemData.data;
|
||||||
|
if (!item) {
|
||||||
|
console.error("No items found with the specified ID:", itemId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve the item:", error);
|
||||||
|
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -72,6 +72,21 @@ export const readFromLog = (): LogEntry[] => {
|
|||||||
return logs ? JSON.parse(logs) : [];
|
return logs ? JSON.parse(logs) : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearLogs = () => {
|
||||||
|
storage.remove("logs");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||||
|
const diagnostics = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
processes: extra?.processes || [],
|
||||||
|
nativeTasks: extra?.nativeTasks || [],
|
||||||
|
focusedProcess: extra?.focusedProcess || null,
|
||||||
|
};
|
||||||
|
writeDebugLog("Download diagnostics", diagnostics);
|
||||||
|
return diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
export function useLog() {
|
export function useLog() {
|
||||||
const context = useContext(LogContext);
|
const context = useContext(LogContext);
|
||||||
if (context === null) {
|
if (context === null) {
|
||||||
|
|||||||
5
utils/secondsToTicks.ts
Normal file
5
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
@@ -203,6 +203,27 @@ export async function hasAccountCredential(
|
|||||||
return stored !== null;
|
return stored !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all credentials for all accounts on all servers.
|
||||||
|
*/
|
||||||
|
export async function clearAllCredentials(): Promise<void> {
|
||||||
|
const previousServers = getPreviousServers();
|
||||||
|
|
||||||
|
for (const server of previousServers) {
|
||||||
|
for (const account of server.accounts) {
|
||||||
|
const key = credentialKey(server.address, account.userId);
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all accounts from servers
|
||||||
|
const clearedServers = previousServers.map((server) => ({
|
||||||
|
...server,
|
||||||
|
accounts: [],
|
||||||
|
}));
|
||||||
|
storage.set("previousServers", JSON.stringify(clearedServers));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an account in a server's accounts list.
|
* Add or update an account in a server's accounts list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,40 +1,46 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
||||||
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
||||||
|
|
||||||
export interface SegmentBuckets {
|
// New Jellyfin 10.11+ Media Segments API types
|
||||||
introSegments: MediaTimeSegment[];
|
interface MediaSegmentDto {
|
||||||
creditSegments: MediaTimeSegment[];
|
Id: string;
|
||||||
recapSegments: MediaTimeSegment[];
|
ItemId: string;
|
||||||
commercialSegments: MediaTimeSegment[];
|
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||||
previewSegments: MediaTimeSegment[];
|
StartTicks: number;
|
||||||
|
EndTicks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
|
interface MediaSegmentsResponse {
|
||||||
|
Items: MediaSegmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy API types (for fallback)
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
IntroStart: number;
|
EpisodeId: string;
|
||||||
|
HideSkipPromptAt: number;
|
||||||
IntroEnd: number;
|
IntroEnd: number;
|
||||||
|
IntroStart: number;
|
||||||
|
ShowSkipPromptAt: number;
|
||||||
Valid: boolean;
|
Valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Credits: { Start: number; End: number; Valid: boolean };
|
Introduction: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
Credits: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10_000_000;
|
const TICKS_PER_SECOND = 10000000;
|
||||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
|
||||||
|
|
||||||
const emptyBuckets = (): SegmentBuckets => ({
|
|
||||||
introSegments: [],
|
|
||||||
creditSegments: [],
|
|
||||||
recapSegments: [],
|
|
||||||
commercialSegments: [],
|
|
||||||
previewSegments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useSegments = (
|
export const useSegments = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@@ -42,6 +48,7 @@ export const useSegments = (
|
|||||||
downloadedFiles: DownloadedItem[] | undefined,
|
downloadedFiles: DownloadedItem[] | undefined,
|
||||||
api: Api | null,
|
api: Api | null,
|
||||||
) => {
|
) => {
|
||||||
|
// Memoize the lookup so the array is only traversed when dependencies change
|
||||||
const downloadedItem = React.useMemo(
|
const downloadedItem = React.useMemo(
|
||||||
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
||||||
[downloadedFiles, itemId],
|
[downloadedFiles, itemId],
|
||||||
@@ -58,110 +65,141 @@ export const useSegments = (
|
|||||||
}
|
}
|
||||||
return fetchAndParseSegments(itemId, api);
|
return fetchAndParseSegments(itemId, api);
|
||||||
},
|
},
|
||||||
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
|
enabled: isOffline ? !!downloadedItem : !!api,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
|
export const getSegmentsForItem = (
|
||||||
introSegments: item.introSegments || [],
|
item: DownloadedItem,
|
||||||
creditSegments: item.creditSegments || [],
|
): {
|
||||||
recapSegments: item.recapSegments || [],
|
introSegments: MediaTimeSegment[];
|
||||||
commercialSegments: item.commercialSegments || [],
|
creditSegments: MediaTimeSegment[];
|
||||||
previewSegments: item.previewSegments || [],
|
} => {
|
||||||
});
|
return {
|
||||||
|
introSegments: item.introSegments || [],
|
||||||
|
creditSegments: item.creditSegments || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
|
/**
|
||||||
|
* Converts Jellyfin ticks to seconds
|
||||||
|
*/
|
||||||
|
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
|
||||||
|
*/
|
||||||
const fetchMediaSegments = async (
|
const fetchMediaSegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets | null> => {
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await getMediaSegmentsApi(api).getItemSegments({
|
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||||
itemId,
|
`${api.basePath}/MediaSegments/${itemId}`,
|
||||||
includeSegmentTypes: [
|
{
|
||||||
MediaSegmentType.Intro,
|
headers: getAuthHeaders(api),
|
||||||
MediaSegmentType.Outro,
|
params: {
|
||||||
MediaSegmentType.Recap,
|
includeSegmentTypes: ["Intro", "Outro"],
|
||||||
MediaSegmentType.Commercial,
|
},
|
||||||
MediaSegmentType.Preview,
|
},
|
||||||
],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const buckets = emptyBuckets();
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
for (const segment of response.data.Items ?? []) {
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
if (segment.StartTicks == null || segment.EndTicks == null) continue;
|
|
||||||
|
response.data.Items.forEach((segment) => {
|
||||||
const timeSegment: MediaTimeSegment = {
|
const timeSegment: MediaTimeSegment = {
|
||||||
startTime: ticksToSeconds(segment.StartTicks),
|
startTime: ticksToSeconds(segment.StartTicks),
|
||||||
endTime: ticksToSeconds(segment.EndTicks),
|
endTime: ticksToSeconds(segment.EndTicks),
|
||||||
text: segment.Type ?? "",
|
text: segment.Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (segment.Type) {
|
switch (segment.Type) {
|
||||||
case MediaSegmentType.Intro:
|
case "Intro":
|
||||||
buckets.introSegments.push(timeSegment);
|
introSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case MediaSegmentType.Outro:
|
case "Outro":
|
||||||
buckets.creditSegments.push(timeSegment);
|
creditSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case MediaSegmentType.Recap:
|
// Optionally handle other types like Recap, Commercial, Preview
|
||||||
buckets.recapSegments.push(timeSegment);
|
default:
|
||||||
break;
|
|
||||||
case MediaSegmentType.Commercial:
|
|
||||||
buckets.commercialSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case MediaSegmentType.Preview:
|
|
||||||
buckets.previewSegments.push(timeSegment);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
return buckets;
|
return { introSegments, creditSegments };
|
||||||
} catch {
|
} catch (_error) {
|
||||||
|
// Return null to indicate we should try legacy endpoints
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
|
/**
|
||||||
|
* Fetches segments using legacy pre-10.11 endpoints
|
||||||
|
*/
|
||||||
const fetchLegacySegments = async (
|
const fetchLegacySegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets> => {
|
): Promise<{
|
||||||
const buckets = emptyBuckets();
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
|
|
||||||
const [introRes, creditRes] = await Promise.allSettled([
|
try {
|
||||||
api.axiosInstance.get<IntroTimestamps>(
|
const [introRes, creditRes] = await Promise.allSettled([
|
||||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
api.axiosInstance.get<IntroTimestamps>(
|
||||||
{ headers: getAuthHeaders(api) },
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
),
|
{ headers: getAuthHeaders(api) },
|
||||||
api.axiosInstance.get<CreditTimestamps>(
|
),
|
||||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
api.axiosInstance.get<CreditTimestamps>(
|
||||||
{ headers: getAuthHeaders(api) },
|
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||||
),
|
{ headers: getAuthHeaders(api) },
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||||
buckets.introSegments.push({
|
introSegments.push({
|
||||||
startTime: introRes.value.data.IntroStart,
|
startTime: introRes.value.data.IntroStart,
|
||||||
endTime: introRes.value.data.IntroEnd,
|
endTime: introRes.value.data.IntroEnd,
|
||||||
text: "Intro",
|
text: "Intro",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
creditRes.status === "fulfilled" &&
|
||||||
|
creditRes.value.data.Credits.Valid
|
||||||
|
) {
|
||||||
|
creditSegments.push({
|
||||||
|
startTime: creditRes.value.data.Credits.Start,
|
||||||
|
endTime: creditRes.value.data.Credits.End,
|
||||||
|
text: "Credits",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch legacy segments", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
|
return { introSegments, creditSegments };
|
||||||
buckets.creditSegments.push({
|
|
||||||
startTime: creditRes.value.data.Credits.Start,
|
|
||||||
endTime: creditRes.value.data.Credits.End,
|
|
||||||
text: "Outro",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return buckets;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAndParseSegments = async (
|
export const fetchAndParseSegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets> => {
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
// Try new API first (Jellyfin 10.11+)
|
||||||
const newSegments = await fetchMediaSegments(itemId, api);
|
const newSegments = await fetchMediaSegments(itemId, api);
|
||||||
return newSegments ?? fetchLegacySegments(itemId, api);
|
if (newSegments) {
|
||||||
|
return newSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy endpoints
|
||||||
|
return fetchLegacySegments(itemId, api);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user