mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
Compare commits
3 Commits
refactor-c
...
ci/pr-vali
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
935cacff81 | ||
|
|
5f59dce0c7 | ||
|
|
3de9b65b7d |
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||||
|
|
||||||
## Main Technologies
|
## Main Technologies
|
||||||
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||||
- `plugins/` – Expo/Metro plugins
|
- `plugins/` – Expo/Metro plugins
|
||||||
|
|
||||||
## Code Quality Standards
|
## Coding Standards
|
||||||
|
|
||||||
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files)
|
||||||
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
|
||||||
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
|
||||||
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
|
||||||
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
|
||||||
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
|
||||||
- Enable and respect strict TypeScript compiler options
|
|
||||||
- Define explicit return types for functions
|
|
||||||
- Use discriminated unions for complex state
|
|
||||||
|
|
||||||
### Code Reliability
|
|
||||||
- Implement comprehensive error handling with try-catch blocks
|
|
||||||
- Validate all external inputs (API responses, user input, query params)
|
|
||||||
- Handle edge cases explicitly (empty arrays, null, undefined)
|
|
||||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
|
||||||
- Add runtime checks for critical operations
|
|
||||||
- Implement proper loading and error states in components
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- Follow BiomeJS formatting and linting rules
|
- Follow BiomeJS formatting and linting rules
|
||||||
- Use `const` over `let`, avoid `var` entirely
|
- Use `const` over `let`, avoid `var` entirely
|
||||||
- Implement proper error boundaries
|
- Implement proper error boundaries
|
||||||
- Use React.memo() for performance optimization when needed
|
- Use React.memo() for performance optimization
|
||||||
- Handle both mobile and TV navigation patterns
|
- Handle both mobile and TV navigation patterns
|
||||||
- Write self-documenting code with clear intent
|
|
||||||
- Add comments only when code complexity requires explanation
|
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
@@ -108,18 +85,6 @@ Exemples:
|
|||||||
- `fix(auth): handle expired JWT tokens`
|
- `fix(auth): handle expired JWT tokens`
|
||||||
- `chore(deps): update Jellyfin SDK`
|
- `chore(deps): update Jellyfin SDK`
|
||||||
|
|
||||||
## Internationalization (i18n)
|
|
||||||
|
|
||||||
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
|
||||||
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
|
||||||
- **NEVER add or remove keys** - Crowdin manages the key structure
|
|
||||||
- **Editing translation values is safe** - Bidirectional sync handles merges
|
|
||||||
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
|
||||||
- **Crowdin workflow**:
|
|
||||||
- New keys added to `en.json` sync to Crowdin automatically
|
|
||||||
- Approved translations sync back to language files via GitHub integration
|
|
||||||
- The source of truth is `en.json` for structure, Crowdin for translations
|
|
||||||
|
|
||||||
## Special Instructions
|
## Special Instructions
|
||||||
|
|
||||||
- Prioritize cross-platform compatibility (mobile + TV)
|
- Prioritize cross-platform compatibility (mobile + TV)
|
||||||
|
|||||||
32
.github/workflows/linting.yml
vendored
32
.github/workflows/linting.yml
vendored
@@ -12,38 +12,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate_pr_title:
|
|
||||||
name: "📝 Validate PR Title"
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
|
||||||
id: lint_pr_title
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Hey there and thank you for opening this pull request! 👋🏼
|
|
||||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
||||||
|
|
||||||
**Error details:**
|
|
||||||
```
|
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
|
||||||
```
|
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
delete: true
|
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: 🔍 Vulnerable Dependencies
|
name: 🔍 Vulnerable Dependencies
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|||||||
136
.github/workflows/pr-validation.yml
vendored
Normal file
136
.github/workflows/pr-validation.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
name: 🚦 PR Validation
|
||||||
|
|
||||||
|
# Uses pull_request_target so the jobs get a write token even on fork PRs (to comment
|
||||||
|
# and label) — same as seerr. SECURITY: never check out or run the PR head's code here;
|
||||||
|
# we only read the title/body from the event payload and run our own scripts from the base.
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-validation-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate_pr_title:
|
||||||
|
name: "📝 Validate PR Title"
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||||
|
|
||||||
|
**Error details:**
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
validate_pr_template:
|
||||||
|
name: "📋 Validate PR Template"
|
||||||
|
# Skip pushes to an existing PR (the body rarely changes) and bot-authored PRs.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
github.event.action != 'synchronize' &&
|
||||||
|
github.actor != 'renovate[bot]' &&
|
||||||
|
github.actor != 'github-actions[bot]'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: "📥 Checkout"
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: "🍞 Setup Bun"
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: "📝 Write PR body to file"
|
||||||
|
env:
|
||||||
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
|
run: printf '%s' "$PR_BODY" > /tmp/pr-body.txt
|
||||||
|
|
||||||
|
- name: "📂 List changed files"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
|
||||||
|
--paginate --jq '.[].filename' > /tmp/pr-files.txt
|
||||||
|
|
||||||
|
- name: "🔎 Validate body against template"
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||||
|
PR_FILES: /tmp/pr-files.txt
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
bun scripts/check-pr-template.mjs /tmp/pr-body.txt > /tmp/pr-issues.json
|
||||||
|
echo "code=$?" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: "💬 Report problems"
|
||||||
|
if: steps.check.outputs.code != '0'
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
let issues;
|
||||||
|
try { issues = JSON.parse(fs.readFileSync('/tmp/pr-issues.json', 'utf8')); }
|
||||||
|
catch { issues = ["The PR template check could not parse the description. Please make sure it follows the template."]; }
|
||||||
|
if (!Array.isArray(issues) || issues.length === 0) issues = ["The PR description does not follow the template."];
|
||||||
|
const body = [
|
||||||
|
"👋 Thanks for the PR! A few things in the description need attention before review:",
|
||||||
|
"",
|
||||||
|
...issues.map((i) => `- ${i}`),
|
||||||
|
"",
|
||||||
|
"Please update the PR description ([template](https://github.com/${{ github.repository }}/blob/develop/.github/pull_request_template.md)). This check re-runs when you edit it.",
|
||||||
|
].join("\n");
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const issue_number = context.payload.pull_request.number;
|
||||||
|
const marker = "<!-- pr-template-check -->";
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
||||||
|
const existing = comments.find((c) => c.body?.includes(marker));
|
||||||
|
const payload = `${marker}\n${body}`;
|
||||||
|
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: payload });
|
||||||
|
else await github.rest.issues.createComment({ owner, repo, issue_number, body: payload });
|
||||||
|
const label = "blocked: template";
|
||||||
|
try { await github.rest.issues.getLabel({ owner, repo, name: label }); }
|
||||||
|
catch { await github.rest.issues.createLabel({ owner, repo, name: label, color: "d93f0b", description: "PR description does not follow the template" }); }
|
||||||
|
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] });
|
||||||
|
core.setFailed(`PR template check failed:\n- ${issues.join("\n- ")}`);
|
||||||
|
|
||||||
|
- name: "✅ Clear problems on success"
|
||||||
|
if: steps.check.outputs.code == '0'
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const issue_number = context.payload.pull_request.number;
|
||||||
|
const marker = "<!-- pr-template-check -->";
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
||||||
|
const existing = comments.find((c) => c.body?.includes(marker));
|
||||||
|
if (existing) await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
|
try { await github.rest.issues.removeLabel({ owner, repo, issue_number, name: "blocked: template" }); } catch {}
|
||||||
@@ -1,238 +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 DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create skip options for a specific segment type
|
|
||||||
* Reduces code duplication across all 5 segment types
|
|
||||||
*/
|
|
||||||
const useSkipOptions = (
|
|
||||||
settingKey:
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview",
|
|
||||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
|
||||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
) => {
|
|
||||||
return useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
selected: option.value === settings?.[settingKey],
|
|
||||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 skipIntroOptions = useSkipOptions(
|
|
||||||
"skipIntro",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipOutroOptions = useSkipOptions(
|
|
||||||
"skipOutro",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipRecapOptions = useSkipOptions(
|
|
||||||
"skipRecap",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipCommercialOptions = useSkipOptions(
|
|
||||||
"skipCommercial",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipPreviewOptions = useSkipOptions(
|
|
||||||
"skipPreview",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={false} className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_intro")}
|
|
||||||
subtitle={t("home.settings.other.skip_intro_description")}
|
|
||||||
disabled={pluginSettings?.skipIntro?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipIntroOptions}
|
|
||||||
disabled={pluginSettings?.skipIntro?.locked}
|
|
||||||
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_${settings.skipIntro}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_intro")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_outro")}
|
|
||||||
subtitle={t("home.settings.other.skip_outro_description")}
|
|
||||||
disabled={pluginSettings?.skipOutro?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipOutroOptions}
|
|
||||||
disabled={pluginSettings?.skipOutro?.locked}
|
|
||||||
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_${settings.skipOutro}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_outro")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_recap")}
|
|
||||||
subtitle={t("home.settings.other.skip_recap_description")}
|
|
||||||
disabled={pluginSettings?.skipRecap?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipRecapOptions}
|
|
||||||
disabled={pluginSettings?.skipRecap?.locked}
|
|
||||||
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_${settings.skipRecap}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_recap")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_commercial")}
|
|
||||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
|
||||||
disabled={pluginSettings?.skipCommercial?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipCommercialOptions}
|
|
||||||
disabled={pluginSettings?.skipCommercial?.locked}
|
|
||||||
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_${settings.skipCommercial}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_commercial")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_preview")}
|
|
||||||
subtitle={t("home.settings.other.skip_preview_description")}
|
|
||||||
disabled={pluginSettings?.skipPreview?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipPreviewOptions}
|
|
||||||
disabled={pluginSettings?.skipPreview?.locked}
|
|
||||||
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_${settings.skipPreview}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_preview")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEGMENT_SKIP_OPTIONS = (
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
): Array<{
|
|
||||||
label: string;
|
|
||||||
value: "none" | "ask" | "auto";
|
|
||||||
}> => [
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import type {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
|
|
||||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -141,8 +139,6 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<CastingMiniPlayer />
|
|
||||||
<CastAutoplayWatcher />
|
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,768 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Player Modal
|
|
||||||
* Protocol-agnostic full-screen player for all supported casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { router, Stack } from "expo-router";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import GoogleCast, {
|
|
||||||
CastState,
|
|
||||||
MediaPlayerState,
|
|
||||||
useCastDevice,
|
|
||||||
useCastState,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import Animated from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
|
||||||
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
|
|
||||||
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
|
|
||||||
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
|
|
||||||
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
|
|
||||||
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
|
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
|
||||||
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
|
||||||
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
|
||||||
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
|
||||||
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
|
||||||
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
|
||||||
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
|
||||||
import { useCasting } from "@/hooks/useCasting";
|
|
||||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
|
||||||
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
|
||||||
import { useCastSelection } from "@/hooks/useCastSelection";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { detectCapabilities } from "@/utils/casting/capabilities";
|
|
||||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
|
||||||
import { getPosterUrl } from "@/utils/casting/helpers";
|
|
||||||
import { resolveSelection } from "@/utils/casting/selection";
|
|
||||||
import type { CastSelection } from "@/utils/casting/types";
|
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
|
||||||
import {
|
|
||||||
type PlaybackController,
|
|
||||||
useRegisterPlaybackController,
|
|
||||||
} from "@/utils/playback/playbackController";
|
|
||||||
|
|
||||||
export default function CastingPlayerScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Chromecast autoplay countdown — watcher hook drives this atom; we render
|
|
||||||
// the overlay here when set, and handle Play-now / Cancel from the user.
|
|
||||||
const castAutoplay = useAtomValue(castAutoplayAtom);
|
|
||||||
const setCastAutoplay = useSetAtom(castAutoplayAtom);
|
|
||||||
|
|
||||||
// Get raw Chromecast state directly - same as old implementation
|
|
||||||
const castState = useCastState();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
|
||||||
useRemoteMediaClient();
|
|
||||||
|
|
||||||
// Fetch full item data from Jellyfin by ID and derive the effective item
|
|
||||||
const { fetchedItem, currentItem } = useCastPlayerItem({
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
mediaStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derive state from raw Chromecast hooks
|
|
||||||
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
|
||||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
|
||||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
|
||||||
const currentDevice = castDevice?.friendlyName ?? null;
|
|
||||||
|
|
||||||
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
|
||||||
// live-progress interpolation, resume-position tracking, trickplay preview.
|
|
||||||
const {
|
|
||||||
sliderProgress,
|
|
||||||
sliderMin,
|
|
||||||
sliderMax,
|
|
||||||
isScrubbing,
|
|
||||||
trickplayTime,
|
|
||||||
setTrickplayTime,
|
|
||||||
progress,
|
|
||||||
resumePositionRef,
|
|
||||||
trickPlayUrl,
|
|
||||||
calculateTrickplayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
|
||||||
|
|
||||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
|
||||||
const castingControls = useCasting(currentItem);
|
|
||||||
const {
|
|
||||||
togglePlayPause,
|
|
||||||
skipForward,
|
|
||||||
skipBackward,
|
|
||||||
setVolume,
|
|
||||||
volume,
|
|
||||||
remoteMediaClient,
|
|
||||||
} = currentItem
|
|
||||||
? castingControls
|
|
||||||
: {
|
|
||||||
togglePlayPause: async () => {},
|
|
||||||
skipForward: async () => {},
|
|
||||||
skipBackward: async () => {},
|
|
||||||
setVolume: () => {},
|
|
||||||
volume: 1,
|
|
||||||
remoteMediaClient: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modal states
|
|
||||||
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
|
||||||
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
|
||||||
|
|
||||||
// Chapter markers (shown for both episodes and movies).
|
|
||||||
const chapters = currentItem?.Chapters;
|
|
||||||
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
|
|
||||||
|
|
||||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
|
|
||||||
|
|
||||||
// Reload the cast stream with a full selection; resolves true on success.
|
|
||||||
const reloadWithSelection = useCallback(
|
|
||||||
async (selection: CastSelection): Promise<boolean> => {
|
|
||||||
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
|
||||||
console.warn("[Casting Player] Cannot reload - missing required data");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const currentPosition = resumePositionRef.current;
|
|
||||||
const result = await loadCastMedia({
|
|
||||||
client: remoteMediaClient,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
|
||||||
item: currentItem,
|
|
||||||
userId: user.Id,
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: {
|
|
||||||
mediaSourceId: selection.mediaSourceId,
|
|
||||||
audioStreamIndex: selection.audioStreamIndex,
|
|
||||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
|
||||||
maxBitrate: selection.maxBitrate,
|
|
||||||
startPositionMs: currentPosition * 1000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to reload stream:",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
currentItem,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { currentSelection, applySelection } = useCastSelection({
|
|
||||||
currentItem,
|
|
||||||
mediaStatus,
|
|
||||||
reload: reloadWithSelection,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Episode/season cluster: episode list, next episode, season data, loader
|
|
||||||
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
|
|
||||||
useCastEpisodes({
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
currentItem,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
|
|
||||||
// cast customData) still describes the previous episode. Used to suppress
|
|
||||||
// episode-dependent secondary UI that would otherwise flash stale data.
|
|
||||||
const isEpisodeTransitioning =
|
|
||||||
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
|
|
||||||
|
|
||||||
// Expose this player to the app-wide remote-control surface while a cast
|
|
||||||
// session is connected. The individual useCasting methods are each
|
|
||||||
// useCallback-wrapped and stable, so depend on them directly rather than on
|
|
||||||
// the whole `castingControls` object literal (rebuilt every render).
|
|
||||||
const {
|
|
||||||
togglePlayPause: castTogglePlayPause,
|
|
||||||
pause: castPause,
|
|
||||||
play: castPlay,
|
|
||||||
stop: castStop,
|
|
||||||
seek: castSeek,
|
|
||||||
setVolume: castSetVolume,
|
|
||||||
} = castingControls;
|
|
||||||
// toggleMute reads the latest volume without making `volume` a useMemo dep.
|
|
||||||
const volumeRef = useRef(volume);
|
|
||||||
volumeRef.current = volume;
|
|
||||||
|
|
||||||
const castController = useMemo<PlaybackController>(
|
|
||||||
() => ({
|
|
||||||
playPause: () => {
|
|
||||||
castTogglePlayPause();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
castPause();
|
|
||||||
},
|
|
||||||
unpause: () => {
|
|
||||||
castPlay();
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
castStop();
|
|
||||||
},
|
|
||||||
seek: (positionMs) => {
|
|
||||||
castSeek(positionMs);
|
|
||||||
},
|
|
||||||
next: () => {
|
|
||||||
if (nextEpisode) loadEpisode(nextEpisode);
|
|
||||||
},
|
|
||||||
previous: () => {
|
|
||||||
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
|
|
||||||
if (idx > 0) loadEpisode(episodes[idx - 1]);
|
|
||||||
},
|
|
||||||
setVolume: (level) => {
|
|
||||||
castSetVolume(level);
|
|
||||||
},
|
|
||||||
toggleMute: () => {
|
|
||||||
castSetVolume(volumeRef.current > 0 ? 0 : 1);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
castTogglePlayPause,
|
|
||||||
castPause,
|
|
||||||
castPlay,
|
|
||||||
castStop,
|
|
||||||
castSeek,
|
|
||||||
castSetVolume,
|
|
||||||
episodes,
|
|
||||||
nextEpisode,
|
|
||||||
loadEpisode,
|
|
||||||
currentItem?.Id,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
useRegisterPlaybackController(
|
|
||||||
castController,
|
|
||||||
castState === CastState.CONNECTED,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The MediaSource currently selected, for deriving its tracks.
|
|
||||||
// Derived from fetchedItem: the slim cast-customData item strips per-source
|
|
||||||
// MediaStreams, so only the full fetched item yields correct track lists.
|
|
||||||
const selectedSource = useMemo(
|
|
||||||
() =>
|
|
||||||
fetchedItem?.MediaSources?.find(
|
|
||||||
(s) => s.Id === currentSelection?.mediaSourceId,
|
|
||||||
) ??
|
|
||||||
fetchedItem?.MediaSources?.[0] ??
|
|
||||||
null,
|
|
||||||
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Real alternate versions (multi-version items).
|
|
||||||
const availableVersions = useMemo(
|
|
||||||
() =>
|
|
||||||
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
|
|
||||||
id: s.Id ?? `source-${i}`,
|
|
||||||
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
|
|
||||||
})),
|
|
||||||
[fetchedItem?.MediaSources, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Quality tiers from the shared ladder, capped to BOTH the device's
|
|
||||||
// capability and the media's own bitrate — a tier above either ceiling
|
|
||||||
// would behave identically to "Max", so it is not offered.
|
|
||||||
const availableQualities = useMemo(() => {
|
|
||||||
const caps = detectCapabilities(castDevice, {
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrate: settings.chromecastMaxBitrate,
|
|
||||||
});
|
|
||||||
const mediaBitrate =
|
|
||||||
selectedSource?.Bitrate ??
|
|
||||||
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
|
|
||||||
Number.POSITIVE_INFINITY;
|
|
||||||
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
|
|
||||||
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
|
|
||||||
}, [
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
selectedSource,
|
|
||||||
fetchedItem?.MediaStreams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const availableAudioTracks = useMemo(() => {
|
|
||||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
|
||||||
if (!streams) return [];
|
|
||||||
return streams
|
|
||||||
.filter((stream) => stream.Type === "Audio")
|
|
||||||
.map((stream) => ({
|
|
||||||
index: stream.Index ?? 0,
|
|
||||||
language: stream.Language || "Unknown",
|
|
||||||
displayTitle:
|
|
||||||
stream.DisplayTitle ||
|
|
||||||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
|
||||||
codec: stream.Codec || "Unknown",
|
|
||||||
channels: stream.Channels,
|
|
||||||
bitrate: stream.BitRate,
|
|
||||||
}));
|
|
||||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
|
||||||
|
|
||||||
const availableSubtitleTracks = useMemo(() => {
|
|
||||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
|
||||||
if (!streams) return [];
|
|
||||||
return streams
|
|
||||||
.filter((stream) => stream.Type === "Subtitle")
|
|
||||||
.map((stream) => ({
|
|
||||||
index: stream.Index ?? 0,
|
|
||||||
language: stream.Language || "Unknown",
|
|
||||||
displayTitle:
|
|
||||||
stream.DisplayTitle ||
|
|
||||||
[
|
|
||||||
stream.Language || "Unknown",
|
|
||||||
stream.IsForced ? " (Forced)" : "",
|
|
||||||
stream.Title ? ` - ${stream.Title}` : "",
|
|
||||||
].join(""),
|
|
||||||
codec: stream.Codec || "Unknown",
|
|
||||||
isForced: stream.IsForced || false,
|
|
||||||
isExternal: stream.IsExternal || false,
|
|
||||||
}));
|
|
||||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
|
||||||
|
|
||||||
// Autoplay overlay's "Play now" — load the queued next episode immediately.
|
|
||||||
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
|
|
||||||
// same start-position derivation) so the cast load is identical regardless
|
|
||||||
// of whether it is triggered by the user or by the countdown timer.
|
|
||||||
const onAutoplayPlayNow = useCallback(async () => {
|
|
||||||
if (!castAutoplay) return;
|
|
||||||
const episode = castAutoplay.nextEpisode;
|
|
||||||
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
|
|
||||||
setCastAutoplay(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const startPositionMs =
|
|
||||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
|
||||||
const result = await loadCastMedia({
|
|
||||||
client: remoteMediaClient,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
|
||||||
item: episode,
|
|
||||||
userId: user.Id,
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: { startPositionMs },
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to load next episode (play now):",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Reset the autoplay counter on explicit user action.
|
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to load next episode (play now):",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setCastAutoplay(null);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
castAutoplay,
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
updateSettings,
|
|
||||||
setCastAutoplay,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Poster URL for the queued next episode (mirrors `posterUrl` for the
|
|
||||||
// currently-playing item — same helper, same dimensions).
|
|
||||||
const autoplayPosterUrl = useMemo(() => {
|
|
||||||
if (!castAutoplay || !api?.basePath) return null;
|
|
||||||
const ep = castAutoplay.nextEpisode;
|
|
||||||
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
|
|
||||||
// call the helper with `undefined`. AutoplayCountdown handles null.
|
|
||||||
if (!ep?.Id) return null;
|
|
||||||
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
|
|
||||||
}, [castAutoplay, api?.basePath]);
|
|
||||||
|
|
||||||
// NOTE: Auto-navigation to casting-player is handled by higher-level
|
|
||||||
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
|
|
||||||
// do NOT call router.replace("/casting-player") here because this component
|
|
||||||
// IS the casting-player screen — doing so would cause redundant navigation loops.
|
|
||||||
|
|
||||||
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
|
||||||
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
|
||||||
useChromecastSegments(currentItem, progress * 1000, false);
|
|
||||||
|
|
||||||
// Swipe down to dismiss gesture
|
|
||||||
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
|
|
||||||
router,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Memoize expensive calculations (before early return)
|
|
||||||
const posterUrl = useMemo(() => {
|
|
||||||
if (!api?.basePath || !currentItem?.Id) return null;
|
|
||||||
|
|
||||||
// For episodes, use SEASON poster instead of episode poster
|
|
||||||
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
|
||||||
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
|
||||||
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
|
||||||
const seasonImageTag = seasonData.ImageTags?.Primary;
|
|
||||||
return seasonImageTag
|
|
||||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
|
||||||
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to item poster for non-episodes or if season data not loaded
|
|
||||||
return getPosterUrl(
|
|
||||||
api.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
260,
|
|
||||||
390,
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
api?.basePath,
|
|
||||||
currentItem?.Id,
|
|
||||||
currentItem?.Type,
|
|
||||||
seasonData?.Id,
|
|
||||||
seasonData?.ImageTags?.Primary,
|
|
||||||
currentItem?.ImageTags?.Primary,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
||||||
|
|
||||||
// Redirect if not connected - check CastState like old implementation
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect immediately when disconnected or no devices
|
|
||||||
if (
|
|
||||||
castState === CastState.NOT_CONNECTED ||
|
|
||||||
castState === CastState.NO_DEVICES_AVAILABLE
|
|
||||||
) {
|
|
||||||
// Use setTimeout to avoid state update during render
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [castState, router]);
|
|
||||||
|
|
||||||
// Also redirect if mediaStatus disappears (media ended or stopped)
|
|
||||||
useEffect(() => {
|
|
||||||
if (castState === CastState.CONNECTED && !mediaStatus) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, 500); // Small delay to allow for media transitions
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [castState, mediaStatus, router]);
|
|
||||||
|
|
||||||
// Show loading while connecting
|
|
||||||
if (castState === CastState.CONNECTING) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size='large' color='#fff' />
|
|
||||||
<Text style={{ color: "#fff", marginTop: 16 }}>
|
|
||||||
{t("casting_player.connecting")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't render if not connected or no media playing
|
|
||||||
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
presentation: "fullScreenModal",
|
|
||||||
animation: "slide_from_bottom",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<GestureDetector gesture={panGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Header - Fixed at top */}
|
|
||||||
<CastPlayerHeader
|
|
||||||
insetTop={insets.top}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
currentDevice={currentDevice}
|
|
||||||
t={t}
|
|
||||||
onDismiss={dismissModal}
|
|
||||||
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
|
|
||||||
onPressSettings={() => setShowSettings(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Title Area — hidden during an episode change to avoid flashing
|
|
||||||
the previous episode's title/season-episode numbers. */}
|
|
||||||
{!isEpisodeTransitioning && (
|
|
||||||
<CastPlayerTitle
|
|
||||||
insetTop={insets.top}
|
|
||||||
currentItem={currentItem}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable content area */}
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingTop: insets.top + 160,
|
|
||||||
paddingBottom: insets.bottom + 500,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* Poster with buffering overlay — force the overlay during an
|
|
||||||
episode change so the loading state covers the stale poster. */}
|
|
||||||
<CastPlayerPoster
|
|
||||||
posterUrl={posterUrl}
|
|
||||||
isBuffering={isBuffering || isEpisodeTransitioning}
|
|
||||||
currentSegment={currentSegment}
|
|
||||||
skipIntro={skipIntro}
|
|
||||||
skipCredits={skipCredits}
|
|
||||||
skipSegment={skipSegment}
|
|
||||||
remoteMediaClient={remoteMediaClient}
|
|
||||||
mediaStatus={mediaStatus}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Fixed control row - positioned independently. Episode-specific
|
|
||||||
buttons are conditional inside; Stop is always available. */}
|
|
||||||
<CastPlayerEpisodeControls
|
|
||||||
insetBottom={insets.bottom}
|
|
||||||
currentItemId={currentItem.Id}
|
|
||||||
episodes={episodes}
|
|
||||||
nextEpisode={nextEpisode}
|
|
||||||
remoteMediaClient={remoteMediaClient}
|
|
||||||
onPressEpisodes={() => setShowEpisodeList(true)}
|
|
||||||
hasChapters={hasChapters}
|
|
||||||
onPressChapters={() => setChapterListVisible(true)}
|
|
||||||
loadEpisode={loadEpisode}
|
|
||||||
router={router}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Fixed bottom controls area */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 10,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
zIndex: 98,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Progress slider with trickplay preview + time display */}
|
|
||||||
<CastPlayerProgressBar
|
|
||||||
sliderProgress={sliderProgress}
|
|
||||||
sliderMin={sliderMin}
|
|
||||||
sliderMax={sliderMax}
|
|
||||||
isScrubbing={isScrubbing}
|
|
||||||
trickplayTime={trickplayTime}
|
|
||||||
setTrickplayTime={setTrickplayTime}
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
calculateTrickplayUrl={calculateTrickplayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
progress={progress}
|
|
||||||
duration={duration}
|
|
||||||
remoteMediaClient={remoteMediaClient}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
chapters={currentItem?.Chapters}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Playback controls */}
|
|
||||||
<CastPlayerTransportControls
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
togglePlayPause={togglePlayPause}
|
|
||||||
skipBackward={skipBackward}
|
|
||||||
skipForward={skipForward}
|
|
||||||
rewindSkipTime={settings?.rewindSkipTime}
|
|
||||||
forwardSkipTime={settings?.forwardSkipTime}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Autoplay countdown overlay — bottom-centred above the episode
|
|
||||||
control row and main controls. 320 wide card; centred via
|
|
||||||
left/right:0 + alignItems:"center". */}
|
|
||||||
{castAutoplay && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 280,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
zIndex: 99,
|
|
||||||
}}
|
|
||||||
pointerEvents='box-none'
|
|
||||||
>
|
|
||||||
<AutoplayCountdown
|
|
||||||
nextEpisode={castAutoplay.nextEpisode}
|
|
||||||
posterUrl={autoplayPosterUrl}
|
|
||||||
secondsRemaining={castAutoplay.secondsRemaining}
|
|
||||||
onPlayNow={onAutoplayPlayNow}
|
|
||||||
onCancel={() => setCastAutoplay(null)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<ChromecastDeviceSheet
|
|
||||||
visible={showDeviceSheet}
|
|
||||||
onClose={() => setShowDeviceSheet(false)}
|
|
||||||
device={
|
|
||||||
currentDevice && castDevice
|
|
||||||
? { friendlyName: currentDevice }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onDisconnect={async () => {
|
|
||||||
try {
|
|
||||||
// End the casting session and disconnect completely
|
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
|
||||||
await sessionManager.endCurrentSession(true);
|
|
||||||
setShowDeviceSheet(false);
|
|
||||||
// Close player immediately after disconnecting
|
|
||||||
setTimeout(() => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Error disconnecting from Chromecast:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
volume={volume}
|
|
||||||
onVolumeChange={async (vol) => {
|
|
||||||
try {
|
|
||||||
setVolume(vol);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Failed to set volume:", error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChromecastEpisodeList
|
|
||||||
visible={showEpisodeList}
|
|
||||||
onClose={() => setShowEpisodeList(false)}
|
|
||||||
currentItem={currentItem}
|
|
||||||
episodes={episodes}
|
|
||||||
api={api}
|
|
||||||
onSelectEpisode={async (episode) => {
|
|
||||||
setShowEpisodeList(false);
|
|
||||||
await loadEpisode(episode);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChapterList
|
|
||||||
visible={chapterListVisible}
|
|
||||||
chapters={chapters}
|
|
||||||
currentPositionMs={progress * 1000}
|
|
||||||
onSeek={(ms) => {
|
|
||||||
remoteMediaClient?.seek({ position: ms / 1000 });
|
|
||||||
}}
|
|
||||||
onClose={() => setChapterListVisible(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChromecastSettingsMenu
|
|
||||||
visible={showSettings}
|
|
||||||
onClose={() => setShowSettings(false)}
|
|
||||||
versions={availableVersions}
|
|
||||||
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
|
|
||||||
onVersionChange={(id) => {
|
|
||||||
if (!fetchedItem) return;
|
|
||||||
applySelection({
|
|
||||||
...resolveSelection(fetchedItem, { mediaSourceId: id }),
|
|
||||||
maxBitrate: currentSelection?.maxBitrate,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
qualities={availableQualities}
|
|
||||||
selectedMaxBitrate={currentSelection?.maxBitrate}
|
|
||||||
onQualityChange={(value) => applySelection({ maxBitrate: value })}
|
|
||||||
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
|
|
||||||
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
|
|
||||||
onAudioChange={(index) =>
|
|
||||||
applySelection({ audioStreamIndex: index })
|
|
||||||
}
|
|
||||||
subtitleTracks={
|
|
||||||
isEpisodeTransitioning ? [] : availableSubtitleTracks
|
|
||||||
}
|
|
||||||
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
|
|
||||||
onSubtitleChange={(index) =>
|
|
||||||
applySelection({ subtitleStreamIndex: index })
|
|
||||||
}
|
|
||||||
playbackSpeed={currentPlaybackSpeed}
|
|
||||||
onPlaybackSpeedChange={(speed) => {
|
|
||||||
setCurrentPlaybackSpeed(speed);
|
|
||||||
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
@@ -59,10 +60,6 @@ import {
|
|||||||
getMpvSubtitleId,
|
getMpvSubtitleId,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import {
|
|
||||||
type PlaybackController,
|
|
||||||
useRegisterPlaybackController,
|
|
||||||
} from "@/utils/playback/playbackController";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||||
|
|
||||||
@@ -406,6 +403,26 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
|
const togglePlay = async () => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
if (isPlaying) {
|
||||||
|
await videoRef.current?.pause();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
|
if (progressInfo) {
|
||||||
|
playbackManager.reportPlaybackProgress(progressInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoRef.current?.play();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
|
if (!offline && api) {
|
||||||
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
|
playbackStartInfo: progressInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||||
|
|
||||||
@@ -479,35 +496,6 @@ export default function DirectPlayerPage() {
|
|||||||
isMuted,
|
isMuted,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Declared after currentPlayStateInfo so the dependency array can reference
|
|
||||||
// it without hitting the temporal dead zone.
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
const progressInfo = currentPlayStateInfo();
|
|
||||||
if (progressInfo) {
|
|
||||||
playbackManager.reportPlaybackProgress(progressInfo);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
const progressInfo = currentPlayStateInfo();
|
|
||||||
if (!offline && api) {
|
|
||||||
await getPlaystateApi(api).reportPlaybackStart({
|
|
||||||
playbackStartInfo: progressInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
lightHapticFeedback,
|
|
||||||
isPlaying,
|
|
||||||
currentPlayStateInfo,
|
|
||||||
playbackManager,
|
|
||||||
offline,
|
|
||||||
api,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const lastUrlUpdateTime = useSharedValue(0);
|
const lastUrlUpdateTime = useSharedValue(0);
|
||||||
const wasJustSeeking = useSharedValue(false);
|
const wasJustSeeking = useSharedValue(false);
|
||||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||||
@@ -936,47 +924,6 @@ export default function DirectPlayerPage() {
|
|||||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// App-wide remote control: wrap the player's existing handlers so remote
|
|
||||||
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
|
|
||||||
const playbackController = useMemo<PlaybackController>(
|
|
||||||
() => ({
|
|
||||||
// togglePlay flips play/pause and reports progress to the server.
|
|
||||||
playPause: () => {
|
|
||||||
void togglePlay();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
pause();
|
|
||||||
},
|
|
||||||
unpause: () => {
|
|
||||||
play();
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
stop();
|
|
||||||
},
|
|
||||||
// PlaybackController seeks in ms; the player's seek already expects ms.
|
|
||||||
seek: (positionMs: number) => {
|
|
||||||
seek(positionMs);
|
|
||||||
},
|
|
||||||
// The player screen has no episode-loading path of its own — episode
|
|
||||||
// navigation is handled inside <Controls> via router replacement — so
|
|
||||||
// next/previous are honest no-ops here.
|
|
||||||
next: () => {},
|
|
||||||
previous: () => {},
|
|
||||||
// Volume is device-level (react-native-volume-manager); the slider sends
|
|
||||||
// 0-1 while setVolumeCb expects 0-100.
|
|
||||||
setVolume: (level: number) => {
|
|
||||||
void setVolumeCb(level * 100);
|
|
||||||
},
|
|
||||||
toggleMute: () => {
|
|
||||||
void toggleMuteCb();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Active for the whole lifetime of the player screen; cleared on unmount.
|
|
||||||
useRegisterPlaybackController(playbackController, true);
|
|
||||||
|
|
||||||
// Determine play method based on stream URL and media source
|
// Determine play method based on stream URL and media source
|
||||||
const playMethod = useMemo<
|
const playMethod = useMemo<
|
||||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||||
@@ -1313,7 +1260,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
122
components/BitRateSheet.tsx
Normal file
122
components/BitRateSheet.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { FilterSheet } from "./filters/FilterSheet";
|
||||||
|
|
||||||
|
export type Bitrate = {
|
||||||
|
key: string;
|
||||||
|
value: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BITRATES: Bitrate[] = [
|
||||||
|
{
|
||||||
|
key: "Max",
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "8 Mb/s",
|
||||||
|
value: 8000000,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "4 Mb/s",
|
||||||
|
value: 4000000,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "2 Mb/s",
|
||||||
|
value: 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "1 Mb/s",
|
||||||
|
value: 1000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "500 Kb/s",
|
||||||
|
value: 500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "250 Kb/s",
|
||||||
|
value: 250000,
|
||||||
|
},
|
||||||
|
].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
onChange: (value: Bitrate) => void;
|
||||||
|
selected?: Bitrate | null;
|
||||||
|
inverted?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BitrateSheet: React.FC<Props> = ({
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
inverted,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (inverted)
|
||||||
|
return BITRATES.slice().sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(b.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
return BITRATES.slice().sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
}, [inverted]);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex shrink'
|
||||||
|
style={{
|
||||||
|
minWidth: 60,
|
||||||
|
maxWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("item_card.quality")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={t("item_card.quality")}
|
||||||
|
data={sorted}
|
||||||
|
values={selected ? [selected] : []}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const label = (item as any).key || "";
|
||||||
|
return label.toLowerCase().includes(query.toLowerCase());
|
||||||
|
}}
|
||||||
|
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||||
|
set={(vals) => {
|
||||||
|
const chosen = vals[0] as Bitrate | undefined;
|
||||||
|
if (chosen) onChange(chosen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,31 +10,36 @@ export type Bitrate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
{ key: "Max", value: undefined },
|
{
|
||||||
{ key: "200 Mb/s", value: 200000000 },
|
key: "Max",
|
||||||
{ key: "180 Mb/s", value: 180000000 },
|
value: undefined,
|
||||||
{ key: "140 Mb/s", value: 140000000 },
|
},
|
||||||
{ key: "120 Mb/s", value: 120000000 },
|
{
|
||||||
{ key: "110 Mb/s", value: 110000000 },
|
key: "8 Mb/s",
|
||||||
{ key: "100 Mb/s", value: 100000000 },
|
value: 8000000,
|
||||||
{ key: "90 Mb/s", value: 90000000 },
|
height: 1080,
|
||||||
{ key: "80 Mb/s", value: 80000000 },
|
},
|
||||||
{ key: "70 Mb/s", value: 70000000 },
|
{
|
||||||
{ key: "60 Mb/s", value: 60000000 },
|
key: "4 Mb/s",
|
||||||
{ key: "50 Mb/s", value: 50000000 },
|
value: 4000000,
|
||||||
{ key: "40 Mb/s", value: 40000000 },
|
height: 1080,
|
||||||
{ key: "30 Mb/s", value: 30000000 },
|
},
|
||||||
{ key: "20 Mb/s", value: 20000000 },
|
{
|
||||||
{ key: "15 Mb/s", value: 15000000 },
|
key: "2 Mb/s",
|
||||||
{ key: "10 Mb/s", value: 10000000 },
|
value: 2000000,
|
||||||
{ key: "8 Mb/s", value: 8000000 },
|
},
|
||||||
{ key: "5 Mb/s", value: 5000000 },
|
{
|
||||||
{ key: "4 Mb/s", value: 4000000 },
|
key: "1 Mb/s",
|
||||||
{ key: "3 Mb/s", value: 3000000 },
|
value: 1000000,
|
||||||
{ key: "2 Mb/s", value: 2000000 },
|
},
|
||||||
{ key: "1 Mb/s", value: 1000000 },
|
{
|
||||||
{ key: "720 Kb/s", value: 720000 },
|
key: "500 Kb/s",
|
||||||
{ key: "420 Kb/s", value: 420000 },
|
value: 500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "250 Kb/s",
|
||||||
|
value: 250000,
|
||||||
|
},
|
||||||
].sort(
|
].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { useCallback, useEffect } from "react";
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
CastState,
|
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useCastState,
|
|
||||||
useDevices,
|
useDevices,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
@@ -26,136 +18,23 @@ export function Chromecast({
|
|||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
// Hooks called for their side effects (keep Chromecast session active)
|
const client = useRemoteMediaClient();
|
||||||
useRemoteMediaClient();
|
const castDevice = useCastDevice();
|
||||||
useCastDevice();
|
const devices = useDevices();
|
||||||
const castState = useCastState();
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
useDevices();
|
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
// Connection menu state
|
|
||||||
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
|
||||||
const isConnected = castState === CastState.CONNECTED;
|
|
||||||
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
|
||||||
const playSessionIdRef = useRef<string | null>(null);
|
|
||||||
const lastContentIdRef = useRef<string | null>(null);
|
|
||||||
const discoveryAttempts = useRef(0);
|
|
||||||
const maxDiscoveryAttempts = 3;
|
|
||||||
|
|
||||||
// Enhanced discovery with retry mechanism - runs once on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isSubscribed = true;
|
(async () => {
|
||||||
let retryTimeout: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const startDiscoveryWithRetry = async () => {
|
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Stop any existing discovery first
|
|
||||||
try {
|
|
||||||
await discoveryManager.stopDiscovery();
|
|
||||||
} catch {
|
|
||||||
// Ignore errors when stopping
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start fresh discovery
|
|
||||||
await discoveryManager.startDiscovery();
|
await discoveryManager.startDiscovery();
|
||||||
discoveryAttempts.current = 0; // Reset on success
|
})();
|
||||||
} catch (error) {
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
console.error("[Chromecast Discovery] Failed:", error);
|
|
||||||
|
|
||||||
// Retry on error
|
|
||||||
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
|
|
||||||
discoveryAttempts.current++;
|
|
||||||
retryTimeout = setTimeout(() => {
|
|
||||||
if (isSubscribed) {
|
|
||||||
startDiscoveryWithRetry();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
startDiscoveryWithRetry();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
if (retryTimeout) {
|
|
||||||
clearTimeout(retryTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [discoveryManager]); // Only re-run if discoveryManager changes
|
|
||||||
|
|
||||||
// Report video progress to Jellyfin server
|
|
||||||
useEffect(() => {
|
|
||||||
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamPosition = mediaStatus.streamPosition || 0;
|
|
||||||
const playerState = mediaStatus.playerState || null;
|
|
||||||
|
|
||||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
|
||||||
const positionChanged =
|
|
||||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
|
||||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
|
||||||
if (!positionChanged && !stateChanged) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentId = mediaStatus.mediaInfo.contentId;
|
|
||||||
|
|
||||||
// Generate a new PlaySessionId when the content changes
|
|
||||||
if (contentId !== lastContentIdRef.current) {
|
|
||||||
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
|
|
||||||
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
|
||||||
/[xy]/g,
|
|
||||||
(c) => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
lastContentIdRef.current = contentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
|
||||||
const isPaused = mediaStatus.playerState === "paused";
|
|
||||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
|
||||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
|
||||||
|
|
||||||
const progressInfo: PlaybackProgressInfo = {
|
|
||||||
ItemId: contentId,
|
|
||||||
PositionTicks: positionTicks,
|
|
||||||
IsPaused: isPaused,
|
|
||||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
|
||||||
PlaySessionId: playSessionIdRef.current || contentId,
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlaystateApi(api)
|
|
||||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
|
||||||
.then(() => {
|
|
||||||
lastReportedProgressRef.current = streamPosition;
|
|
||||||
lastReportedPlayerStateRef.current = playerState;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to report Chromecast progress:", error);
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
mediaStatus?.mediaInfo?.contentId,
|
|
||||||
mediaStatus?.playerState,
|
|
||||||
mediaStatus?.mediaInfo?.contentUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
@@ -164,92 +43,50 @@ export function Chromecast({
|
|||||||
[Platform.OS],
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle press - show connection menu when connected, otherwise show cast dialog
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
if (isConnected) {
|
|
||||||
if (mediaStatus?.currentItemId) {
|
|
||||||
// Media is playing - navigate to full player
|
|
||||||
router.push("/casting-player");
|
|
||||||
} else {
|
|
||||||
// Connected but no media - show connection menu
|
|
||||||
setShowConnectionMenu(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not connected - show cast dialog
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}, [isConnected, mediaStatus?.currentItemId]);
|
|
||||||
|
|
||||||
// Handle disconnect from Chromecast
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
|
||||||
await sessionManager.endCurrentSession(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Chromecast] Disconnect error:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<>
|
<Pressable
|
||||||
<Pressable className='mr-4' onPress={handlePress} {...props}>
|
className='mr-4'
|
||||||
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
name='cast'
|
|
||||||
size={22}
|
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<ChromecastConnectionMenu
|
|
||||||
visible={showConnectionMenu}
|
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
className='mr-2'
|
className='mr-2'
|
||||||
background={false}
|
background={false}
|
||||||
onPress={handlePress}
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
name='cast'
|
|
||||||
size={22}
|
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
|
||||||
/>
|
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
<ChromecastConnectionMenu
|
|
||||||
visible={showConnectionMenu}
|
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RoundButton
|
||||||
<RoundButton size='large' onPress={handlePress} {...props}>
|
size='large'
|
||||||
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
name='cast'
|
|
||||||
size={22}
|
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
|
||||||
/>
|
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
<ChromecastConnectionMenu
|
|
||||||
visible={showConnectionMenu}
|
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type {
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ interface PlatformDropdownProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onOptionSelect?: (value?: any) => void;
|
onOptionSelect?: (value?: any) => void;
|
||||||
disabled?: boolean;
|
|
||||||
expoUIConfig?: {
|
expoUIConfig?: {
|
||||||
hostStyle?: any;
|
hostStyle?: any;
|
||||||
};
|
};
|
||||||
@@ -214,9 +213,6 @@ const PlatformDropdownComponent = ({
|
|||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
onOptionSelect,
|
onOptionSelect,
|
||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
|
|
||||||
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
|
|
||||||
disabled: isDisabled,
|
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
@@ -269,13 +265,6 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
if (isDisabled) {
|
|
||||||
return (
|
|
||||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||||
@@ -428,14 +417,8 @@ const PlatformDropdownComponent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
onPress={handlePress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
|
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
MediaPlayerState,
|
MediaStreamType,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useCastDevice,
|
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
@@ -33,8 +32,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { chromecast } from "../utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -56,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
@@ -109,11 +111,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = [
|
const options = ["Chromecast", "Device", "Cancel"];
|
||||||
t("casting_player.chromecast"),
|
|
||||||
t("casting_player.device"),
|
|
||||||
t("casting_player.cancel"),
|
|
||||||
];
|
|
||||||
const cancelButtonIndex = 2;
|
const cancelButtonIndex = 2;
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -122,14 +120,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
async (selectedIndex: number | undefined) => {
|
async (selectedIndex: number | undefined) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
// Compare item IDs AND check if media is actually playing (not stopped/idle)
|
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||||
const currentContentId = mediaStatus?.mediaInfo?.contentId;
|
|
||||||
const isMediaActive =
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
|
||||||
const isOpeningCurrentlyPlayingMedia =
|
const isOpeningCurrentlyPlayingMedia =
|
||||||
isMediaActive && currentContentId && currentContentId === item?.Id;
|
currentTitle && currentTitle === item?.Name;
|
||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -137,8 +130,30 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (state && state !== PlayServicesState.SUCCESS) {
|
if (state && state !== PlayServicesState.SUCCESS) {
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
} else {
|
} else {
|
||||||
if (!api || !user?.Id || !item?.Id) {
|
// Check if user wants H265 for Chromecast
|
||||||
console.warn("Missing parameters for Chromecast streaming");
|
const enableH265 = settings.enableH265ForChromecast;
|
||||||
|
|
||||||
|
// Validate required parameters before calling getStreamUrl
|
||||||
|
if (!api) {
|
||||||
|
console.warn("API not available for Chromecast streaming");
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.missing_parameters"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user?.Id) {
|
||||||
|
console.warn(
|
||||||
|
"User not authenticated for Chromecast streaming",
|
||||||
|
);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.missing_parameters"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item?.Id) {
|
||||||
|
console.warn("Item not available for Chromecast streaming");
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.missing_parameters"),
|
t("player.missing_parameters"),
|
||||||
@@ -146,28 +161,24 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPositionMs =
|
// Get a new URL with the Chromecast device profile
|
||||||
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
try {
|
||||||
|
const data = await getStreamUrl({
|
||||||
const result = await loadCastMedia({
|
|
||||||
client,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: {
|
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
maxBitrate: selectedOptions.bitrate?.value,
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
|
||||||
startPositionMs,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
console.log("URL: ", data?.url, enableH265);
|
||||||
console.error("[PlayButton] cast load failed:", result.error);
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.could_not_create_stream_for_chromecast"),
|
t("player.could_not_create_stream_for_chromecast"),
|
||||||
@@ -175,8 +186,85 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpeningCurrentlyPlayingMedia) {
|
// Calculate start time in seconds from playback position
|
||||||
router.push("/casting-player");
|
const startTimeSeconds =
|
||||||
|
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||||
|
|
||||||
|
// Calculate stream duration in seconds from runtime
|
||||||
|
const streamDurationSeconds = item.RunTimeTicks
|
||||||
|
? item.RunTimeTicks / 10000000
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
client
|
||||||
|
.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentId: item.Id,
|
||||||
|
contentUrl: data?.url,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
streamType: MediaStreamType.BUFFERED,
|
||||||
|
streamDuration: streamDurationSeconds,
|
||||||
|
metadata:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
type: "tvShow",
|
||||||
|
title: item.Name || "",
|
||||||
|
episodeNumber: item.IndexNumber || 0,
|
||||||
|
seasonNumber: item.ParentIndexNumber || 0,
|
||||||
|
seriesTitle: item.SeriesName || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getParentBackdropImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: item.Type === "Movie"
|
||||||
|
? {
|
||||||
|
type: "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "generic",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: startTimeSeconds,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// state is already set when reopening current media, so skip it here.
|
||||||
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CastContext.showExpandedControls();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -192,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
client,
|
||||||
castDevice,
|
|
||||||
settings,
|
settings,
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
|
|
||||||
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
|
|
||||||
* fires regardless of which screen is open.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
|
|
||||||
|
|
||||||
export function CastAutoplayWatcher() {
|
|
||||||
useCastAutoplay();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Mini Player
|
|
||||||
* Works with all supported casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import {
|
|
||||||
MediaPlayerState,
|
|
||||||
useCastDevice,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import Animated, {
|
|
||||||
SlideInDown,
|
|
||||||
SlideOutDown,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
|
||||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
export const CastingMiniPlayer: React.FC = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
const remoteMediaClient = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
|
|
||||||
|
|
||||||
// Trickplay support - pass currentItem as BaseItemDto or null
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
||||||
currentItem || null,
|
|
||||||
);
|
|
||||||
const [trickplayTime, setTrickplayTime] = useState({
|
|
||||||
hours: 0,
|
|
||||||
minutes: 0,
|
|
||||||
seconds: 0,
|
|
||||||
});
|
|
||||||
const isScrubbing = useRef(false);
|
|
||||||
|
|
||||||
// Slider shared values
|
|
||||||
const sliderProgress = useSharedValue(0);
|
|
||||||
const sliderMin = useSharedValue(0);
|
|
||||||
const sliderMax = useSharedValue(100);
|
|
||||||
|
|
||||||
// Live progress state that updates every second when playing
|
|
||||||
const [liveProgress, setLiveProgress] = useState(
|
|
||||||
mediaStatus?.streamPosition || 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track baseline for elapsed-time computation
|
|
||||||
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
|
|
||||||
const baselineTimestampRef = useRef(Date.now());
|
|
||||||
|
|
||||||
// Sync live progress with mediaStatus and poll every second when playing
|
|
||||||
useEffect(() => {
|
|
||||||
// Resync baseline whenever mediaStatus reports a new position
|
|
||||||
if (mediaStatus?.streamPosition !== undefined) {
|
|
||||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
|
||||||
baselineTimestampRef.current = Date.now();
|
|
||||||
setLiveProgress(mediaStatus.streamPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update based on elapsed real time when playing
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
|
|
||||||
const elapsed =
|
|
||||||
((Date.now() - baselineTimestampRef.current) *
|
|
||||||
(mediaStatus.playbackRate || 1)) /
|
|
||||||
1000;
|
|
||||||
setLiveProgress(baselinePositionRef.current + elapsed);
|
|
||||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
|
||||||
// Sync with actual position when paused/buffering
|
|
||||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
|
||||||
baselineTimestampRef.current = Date.now();
|
|
||||||
setLiveProgress(mediaStatus.streamPosition);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [
|
|
||||||
mediaStatus?.playerState,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
mediaStatus?.playbackRate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const progress = liveProgress * 1000; // Convert to ms
|
|
||||||
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
|
||||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
|
||||||
|
|
||||||
// Update slider max value when duration changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (duration > 0) {
|
|
||||||
sliderMax.value = duration;
|
|
||||||
}
|
|
||||||
}, [duration, sliderMax]);
|
|
||||||
|
|
||||||
// Sync slider progress with live progress (when not scrubbing)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isScrubbing.current && progress >= 0) {
|
|
||||||
sliderProgress.value = progress;
|
|
||||||
}
|
|
||||||
}, [progress, sliderProgress]);
|
|
||||||
|
|
||||||
// For episodes, use series poster; for other content, use item poster
|
|
||||||
const posterUrl = useMemo(() => {
|
|
||||||
if (!api?.basePath || !currentItem) return null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentItem.Type === "Episode" &&
|
|
||||||
currentItem.SeriesId &&
|
|
||||||
currentItem.ParentIndexNumber !== undefined &&
|
|
||||||
currentItem.SeasonId
|
|
||||||
) {
|
|
||||||
// Build series poster URL using SeriesId and series-level image tag
|
|
||||||
const imageTag = currentItem.SeriesPrimaryImageTag || "";
|
|
||||||
const tagParam = imageTag ? `&tag=${imageTag}` : "";
|
|
||||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-episodes, use item's own poster
|
|
||||||
return getPosterUrl(
|
|
||||||
api.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
80,
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
}, [api?.basePath, currentItem]);
|
|
||||||
|
|
||||||
// Hide mini player when:
|
|
||||||
// - No cast device connected
|
|
||||||
// - No media info (currentItem)
|
|
||||||
// - No media status
|
|
||||||
// - Media is stopped (IDLE state)
|
|
||||||
// - Media is unknown state
|
|
||||||
const playerState = mediaStatus?.playerState;
|
|
||||||
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
|
||||||
|
|
||||||
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
||||||
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
router.push("/casting-player");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTogglePlayPause = () => {
|
|
||||||
if (isPlaying) {
|
|
||||||
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
|
||||||
console.error("[CastingMiniPlayer] Pause error:", error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
remoteMediaClient?.play()?.catch((error: unknown) => {
|
|
||||||
console.error("[CastingMiniPlayer] Play error:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
||||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: "#333",
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Interactive progress slider with trickplay */}
|
|
||||||
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 20 }}
|
|
||||||
progress={sliderProgress}
|
|
||||||
minimumValue={sliderMin}
|
|
||||||
maximumValue={sliderMax}
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: protocolColor,
|
|
||||||
bubbleBackgroundColor: protocolColor,
|
|
||||||
bubbleTextColor: "#fff",
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
isScrubbing.current = true;
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
// Calculate trickplay preview
|
|
||||||
const progressInTicks = msToTicks(value);
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
|
|
||||||
// Update time display for trickplay bubble
|
|
||||||
const progressInSeconds = Math.floor(
|
|
||||||
ticksToSeconds(progressInTicks),
|
|
||||||
);
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTrickplayTime({ hours, minutes, seconds });
|
|
||||||
}}
|
|
||||||
onSlidingComplete={(value) => {
|
|
||||||
isScrubbing.current = false;
|
|
||||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
|
||||||
const positionSeconds = value / 1000;
|
|
||||||
if (remoteMediaClient && duration > 0) {
|
|
||||||
remoteMediaClient
|
|
||||||
.seek({ position: positionSeconds })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Mini Player] Seek error:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderBubble={() => (
|
|
||||||
<CastTrickplayBubble
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
trickplayTime={trickplayTime}
|
|
||||||
tileWidth={190}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
bubbleMaxWidth={190}
|
|
||||||
bubbleWidth={190}
|
|
||||||
bubbleTranslateY={-20}
|
|
||||||
sliderHeight={3}
|
|
||||||
thumbWidth={14}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable onPress={handlePress}>
|
|
||||||
{/* Content */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 12,
|
|
||||||
paddingTop: 6,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Poster */}
|
|
||||||
{posterUrl && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentItem.Name}
|
|
||||||
</Text>
|
|
||||||
{currentItem.SeriesName && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentItem.SeriesName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={12} color={protocolColor} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: protocolColor,
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{castDevice.friendlyName || "Chromecast"}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTime(progress)} / {formatTime(duration)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Stop button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
|
||||||
console.error("[CastingMiniPlayer] Stop error:", error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
style={{ padding: 8 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='stop' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Play/Pause button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleTogglePlayPause();
|
|
||||||
}}
|
|
||||||
style={{ padding: 8 }}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={28}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Episode Controls
|
|
||||||
* Fixed control row: episode list, previous, next, stop.
|
|
||||||
* Episode-specific buttons (list / previous / next) are conditional;
|
|
||||||
* Stop is always rendered so movies still get a Stop button.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { ImperativeRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface CastPlayerEpisodeControlsProps {
|
|
||||||
/** Bottom safe-area inset, used to offset the fixed control row. */
|
|
||||||
insetBottom: number;
|
|
||||||
/** Id of the currently playing episode. */
|
|
||||||
currentItemId: BaseItemDto["Id"];
|
|
||||||
/** Full episode list for the series. */
|
|
||||||
episodes: BaseItemDto[];
|
|
||||||
/** Next episode in the list, or null if none. */
|
|
||||||
nextEpisode: BaseItemDto | null;
|
|
||||||
/** Remote media client, or null when no session. */
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
/** Open the episode list modal. */
|
|
||||||
onPressEpisodes: () => void;
|
|
||||||
/** Whether the current item exposes chapter markers. */
|
|
||||||
hasChapters: boolean;
|
|
||||||
/** Open the chapter list modal. */
|
|
||||||
onPressChapters: () => void;
|
|
||||||
/** Load a different episode on the Chromecast. */
|
|
||||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
|
||||||
/** Expo Router instance for navigation on stop. */
|
|
||||||
router: ImperativeRouter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerEpisodeControls({
|
|
||||||
insetBottom,
|
|
||||||
currentItemId,
|
|
||||||
episodes,
|
|
||||||
nextEpisode,
|
|
||||||
remoteMediaClient,
|
|
||||||
onPressEpisodes,
|
|
||||||
hasChapters,
|
|
||||||
onPressChapters,
|
|
||||||
loadEpisode,
|
|
||||||
router,
|
|
||||||
}: CastPlayerEpisodeControlsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const hasEpisodeList = episodes.length > 0;
|
|
||||||
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
|
|
||||||
const hasNext = nextEpisode != null;
|
|
||||||
|
|
||||||
// Count of buttons actually rendered (Stop is always rendered).
|
|
||||||
const buttonCount =
|
|
||||||
1 +
|
|
||||||
(hasEpisodeList ? 1 : 0) +
|
|
||||||
(hasChapters ? 1 : 0) +
|
|
||||||
(hasPrevious ? 1 : 0) +
|
|
||||||
(hasNext ? 1 : 0);
|
|
||||||
|
|
||||||
// When Stop is the only button (movies), render it full-width with a label.
|
|
||||||
const isLoneStop = buttonCount === 1;
|
|
||||||
|
|
||||||
// Each button stretches evenly only when the row holds more than one;
|
|
||||||
// a lone Stop button keeps its intrinsic size and stays centered.
|
|
||||||
const buttonStyle = {
|
|
||||||
...(buttonCount > 1 ? { flex: 1 } : {}),
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
flexDirection: "row" as const,
|
|
||||||
justifyContent: "center" as const,
|
|
||||||
alignItems: "center" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insetBottom + 200,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
|
|
||||||
{hasEpisodeList && (
|
|
||||||
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
|
|
||||||
<Ionicons name='list' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
|
|
||||||
{hasChapters && (
|
|
||||||
<Pressable onPress={onPressChapters} style={buttonStyle}>
|
|
||||||
<Ionicons name='bookmarks' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Previous episode button - only rendered when a previous episode exists */}
|
|
||||||
{hasPrevious && (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
const currentIndex = episodes.findIndex(
|
|
||||||
(ep) => ep.Id === currentItemId,
|
|
||||||
);
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
await loadEpisode(episodes[currentIndex - 1]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={buttonStyle}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-back' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Next episode button - only rendered when a next episode exists */}
|
|
||||||
{hasNext && (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
if (nextEpisode) {
|
|
||||||
await loadEpisode(nextEpisode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={buttonStyle}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
try {
|
|
||||||
// Stop the current media playback (don't disconnect from Chromecast)
|
|
||||||
if (remoteMediaClient) {
|
|
||||||
await remoteMediaClient.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate back/close the player (mini player will disappear since no media is playing)
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Error stopping playback:", error);
|
|
||||||
// Navigate anyway
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
|
|
||||||
>
|
|
||||||
<Ionicons name='stop-circle' size={22} color='white' />
|
|
||||||
{isLoneStop && (
|
|
||||||
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
|
|
||||||
{t("casting_player.stop")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Header
|
|
||||||
* Fixed top bar: dismiss button, connection indicator, settings button.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface CastPlayerHeaderProps {
|
|
||||||
/** Top safe-area inset, used to offset the fixed header. */
|
|
||||||
insetTop: number;
|
|
||||||
/** Streamyfin protocol accent color. */
|
|
||||||
protocolColor: string;
|
|
||||||
/** Friendly name of the connected cast device, or null. */
|
|
||||||
currentDevice: string | null;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
/** Dismiss the casting player modal. */
|
|
||||||
onDismiss: () => void;
|
|
||||||
/** Open the device sheet (connection indicator press). */
|
|
||||||
onPressConnectionIndicator: () => void;
|
|
||||||
/** Open the settings menu. */
|
|
||||||
onPressSettings: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerHeader({
|
|
||||||
insetTop,
|
|
||||||
protocolColor,
|
|
||||||
currentDevice,
|
|
||||||
t,
|
|
||||||
onDismiss,
|
|
||||||
onPressConnectionIndicator,
|
|
||||||
onPressSettings,
|
|
||||||
}: CastPlayerHeaderProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insetTop + 8,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
|
|
||||||
<Ionicons name='chevron-down' size={32} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Connection indicator */}
|
|
||||||
<Pressable
|
|
||||||
onPress={onPressConnectionIndicator}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderRadius: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: protocolColor,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "500",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentDevice || t("casting_player.unknown_device")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={onPressSettings}
|
|
||||||
style={{ padding: 8, marginRight: -8 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='settings-outline' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Poster
|
|
||||||
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
|
||||||
import {
|
|
||||||
MediaPlayerState,
|
|
||||||
type MediaStatus,
|
|
||||||
type RemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
|
|
||||||
|
|
||||||
interface CastPlayerPosterProps {
|
|
||||||
/** Poster image URL, or null when unavailable. */
|
|
||||||
posterUrl: string | null;
|
|
||||||
/** Whether the cast media is currently buffering. */
|
|
||||||
isBuffering: boolean;
|
|
||||||
/** The current playback segment (intro/credits/etc.), or null. */
|
|
||||||
currentSegment: ChromecastSegments["currentSegment"];
|
|
||||||
/** Skip the intro segment. */
|
|
||||||
skipIntro: ChromecastSegments["skipIntro"];
|
|
||||||
/** Skip the credits segment. */
|
|
||||||
skipCredits: ChromecastSegments["skipCredits"];
|
|
||||||
/** Skip the current generic segment. */
|
|
||||||
skipSegment: ChromecastSegments["skipSegment"];
|
|
||||||
/** The remote media client, or null when no session. */
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
/** Raw Chromecast media status. */
|
|
||||||
mediaStatus: MediaStatus | null;
|
|
||||||
/** Theme accent color. */
|
|
||||||
protocolColor: string;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerPoster({
|
|
||||||
posterUrl,
|
|
||||||
isBuffering,
|
|
||||||
currentSegment,
|
|
||||||
skipIntro,
|
|
||||||
skipCredits,
|
|
||||||
skipSegment,
|
|
||||||
remoteMediaClient,
|
|
||||||
mediaStatus,
|
|
||||||
protocolColor,
|
|
||||||
t,
|
|
||||||
}: CastPlayerPosterProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 280,
|
|
||||||
height: 420,
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{posterUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='film-outline' size={64} color='#333' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Skip intro/credits bar at bottom of poster */}
|
|
||||||
{currentSegment && (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
if (!remoteMediaClient) return;
|
|
||||||
try {
|
|
||||||
const seekFn = async (positionMs: number) => {
|
|
||||||
if (
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
|
||||||
) {
|
|
||||||
await remoteMediaClient.seek({
|
|
||||||
position: positionMs / 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (currentSegment.type === "intro") {
|
|
||||||
await skipIntro(seekFn);
|
|
||||||
} else if (currentSegment.type === "credits") {
|
|
||||||
await skipCredits(seekFn);
|
|
||||||
} else {
|
|
||||||
await skipSegment(seekFn);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Skip error:", error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-forward' size={18} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buffering overlay */}
|
|
||||||
{isBuffering && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0,0,0,0.7)",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size='large' color={protocolColor} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
marginTop: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("casting_player.buffering")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Progress Bar
|
|
||||||
* Progress slider with trickplay preview bubble and current/end time display.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import type { SharedValue } from "react-native-reanimated";
|
|
||||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
|
||||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
|
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
|
||||||
|
|
||||||
interface CastPlayerProgressBarProps {
|
|
||||||
/** Shared value tracking the slider progress, in milliseconds. */
|
|
||||||
sliderProgress: SharedValue<number>;
|
|
||||||
/** Shared value for the slider minimum, in milliseconds. */
|
|
||||||
sliderMin: SharedValue<number>;
|
|
||||||
/** Shared value for the slider maximum, in milliseconds. */
|
|
||||||
sliderMax: SharedValue<number>;
|
|
||||||
/** Mutable ref flag set true while the user is scrubbing. */
|
|
||||||
isScrubbing: { current: boolean };
|
|
||||||
/** Trickplay time display state for the bubble. */
|
|
||||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
|
||||||
/** Updates the trickplay time display state. */
|
|
||||||
setTrickplayTime: (time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
}) => void;
|
|
||||||
/** Current trickplay image URL/coordinates, or null. */
|
|
||||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
|
||||||
/** Computes the trickplay URL for a given progress in ticks. */
|
|
||||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
|
||||||
/** Parsed trickplay metadata, or null. */
|
|
||||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
|
||||||
/** Current playback progress, in seconds. */
|
|
||||||
progress: number;
|
|
||||||
/** Total media duration, in seconds. */
|
|
||||||
duration: number;
|
|
||||||
/** Remote media client, or null when no session. */
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
/** Theme color used for the slider track and bubbles. */
|
|
||||||
protocolColor: string;
|
|
||||||
/** Chapter markers for the current item, or null/undefined if none. */
|
|
||||||
chapters?: ChapterInfo[] | null;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerProgressBar({
|
|
||||||
sliderProgress,
|
|
||||||
sliderMin,
|
|
||||||
sliderMax,
|
|
||||||
isScrubbing,
|
|
||||||
trickplayTime,
|
|
||||||
setTrickplayTime,
|
|
||||||
trickPlayUrl,
|
|
||||||
calculateTrickplayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
remoteMediaClient,
|
|
||||||
protocolColor,
|
|
||||||
chapters,
|
|
||||||
t,
|
|
||||||
}: CastPlayerProgressBarProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Progress slider with trickplay preview */}
|
|
||||||
<View style={{ marginTop: 8, height: 40 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={sliderProgress}
|
|
||||||
minimumValue={sliderMin}
|
|
||||||
maximumValue={sliderMax}
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: protocolColor,
|
|
||||||
bubbleBackgroundColor: protocolColor,
|
|
||||||
bubbleTextColor: "#fff",
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
isScrubbing.current = true;
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
// Calculate trickplay preview
|
|
||||||
const progressInTicks = msToTicks(value);
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
|
|
||||||
// Update time display for trickplay bubble
|
|
||||||
const progressInSeconds = Math.floor(
|
|
||||||
ticksToSeconds(progressInTicks),
|
|
||||||
);
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTrickplayTime({ hours, minutes, seconds });
|
|
||||||
}}
|
|
||||||
onSlidingComplete={(value) => {
|
|
||||||
isScrubbing.current = false;
|
|
||||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
|
||||||
const positionSeconds = value / 1000;
|
|
||||||
if (remoteMediaClient && duration > 0) {
|
|
||||||
remoteMediaClient
|
|
||||||
.seek({ position: positionSeconds })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Casting Player] Seek error:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderBubble={() => (
|
|
||||||
<CastTrickplayBubble
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
trickplayTime={trickplayTime}
|
|
||||||
tileWidth={220}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
bubbleMaxWidth={220}
|
|
||||||
bubbleWidth={220}
|
|
||||||
bubbleTranslateY={-20}
|
|
||||||
sliderHeight={6}
|
|
||||||
thumbWidth={16}
|
|
||||||
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
|
|
||||||
/>
|
|
||||||
<ChapterTicks
|
|
||||||
markers={chapterMarkers(chapters, duration * 1000)}
|
|
||||||
height={4}
|
|
||||||
color='#cccccc'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Time display */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
||||||
{formatTime(progress * 1000)}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
||||||
{t("casting_player.ending_at", {
|
|
||||||
time: calculateEndingTime(progress * 1000, duration * 1000),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
||||||
{formatTime(duration * 1000)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Title Area
|
|
||||||
* Fixed title bar: item title and optional grey episode/season info.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { truncateTitle } from "@/utils/casting/helpers";
|
|
||||||
|
|
||||||
interface CastPlayerTitleProps {
|
|
||||||
/** Top safe-area inset, used to offset the fixed title area. */
|
|
||||||
insetTop: number;
|
|
||||||
/** The currently playing item. */
|
|
||||||
currentItem: BaseItemDto;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerTitle({
|
|
||||||
insetTop,
|
|
||||||
currentItem,
|
|
||||||
t,
|
|
||||||
}: CastPlayerTitleProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insetTop + 50,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 95,
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
||||||
paddingVertical: 16,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Grey episode/season info */}
|
|
||||||
{currentItem.Type === "Episode" &&
|
|
||||||
currentItem.ParentIndexNumber !== undefined &&
|
|
||||||
currentItem.IndexNumber !== undefined && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 15,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("casting_player.season_episode_format", {
|
|
||||||
season: currentItem.ParentIndexNumber,
|
|
||||||
episode: currentItem.IndexNumber,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Transport Controls
|
|
||||||
* Playback transport row: rewind, play/pause, forward.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface CastPlayerTransportControlsProps {
|
|
||||||
/** Whether playback is currently playing. */
|
|
||||||
isPlaying: boolean;
|
|
||||||
/** Toggle play/pause on the Chromecast. */
|
|
||||||
togglePlayPause: () => Promise<void>;
|
|
||||||
/** Skip backward by the given number of seconds. */
|
|
||||||
skipBackward: (seconds: number) => Promise<void>;
|
|
||||||
/** Skip forward by the given number of seconds. */
|
|
||||||
skipForward: (seconds: number) => Promise<void>;
|
|
||||||
/** Configured rewind skip time in seconds, shown on the rewind button. */
|
|
||||||
rewindSkipTime: number | null | undefined;
|
|
||||||
/** Configured forward skip time in seconds, shown on the forward button. */
|
|
||||||
forwardSkipTime: number | null | undefined;
|
|
||||||
/** Accent color used for the play/pause button background. */
|
|
||||||
protocolColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerTransportControls({
|
|
||||||
isPlaying,
|
|
||||||
togglePlayPause,
|
|
||||||
skipBackward,
|
|
||||||
skipForward,
|
|
||||||
rewindSkipTime,
|
|
||||||
forwardSkipTime,
|
|
||||||
protocolColor,
|
|
||||||
}: CastPlayerTransportControlsProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 32,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Rewind (use settings) */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => skipBackward(rewindSkipTime ?? 10)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={48}
|
|
||||||
color='white'
|
|
||||||
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
|
||||||
/>
|
|
||||||
{rewindSkipTime != null && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rewindSkipTime}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Play/Pause */}
|
|
||||||
<Pressable
|
|
||||||
onPress={togglePlayPause}
|
|
||||||
style={{
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
borderRadius: 36,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={36}
|
|
||||||
color='white'
|
|
||||||
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Forward (use settings) */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => skipForward(forwardSkipTime ?? 10)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='refresh-outline' size={48} color='white' />
|
|
||||||
{forwardSkipTime != null && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{forwardSkipTime}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared scrub-preview bubble for the casting progress bars.
|
|
||||||
*
|
|
||||||
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
|
|
||||||
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
|
|
||||||
* component therefore does NO horizontal positioning — it only anchors itself
|
|
||||||
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { formatTrickplayTime } from "@/utils/casting/helpers";
|
|
||||||
|
|
||||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
|
||||||
|
|
||||||
interface CastTrickplayBubbleProps {
|
|
||||||
/** Current trickplay image URL/coordinates, or null. */
|
|
||||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
|
||||||
/** Parsed trickplay metadata, or null. */
|
|
||||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
|
||||||
/** Scrub time to display. */
|
|
||||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
|
||||||
/** Trickplay tile width in px (220 main player, 140 mini-player). */
|
|
||||||
tileWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastTrickplayBubble({
|
|
||||||
trickPlayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
trickplayTime,
|
|
||||||
tileWidth,
|
|
||||||
}: CastTrickplayBubbleProps) {
|
|
||||||
const timeText = (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTrickplayTime(trickplayTime)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Anchored to the bottom of the slider-positioned container, growing upward,
|
|
||||||
// and filling the container width (left/right: 0) so it stays centred on the
|
|
||||||
// thumb. No horizontal maths here — the slider owns horizontal placement.
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{timeText}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{timeText}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
style={{
|
|
||||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
|
||||||
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Connection Menu
|
|
||||||
* Shows device info, volume control, and disconnect option
|
|
||||||
* Simple menu for when connected but not actively controlling playback
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Modal, Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import { useCastDevice, useCastSession } from "react-native-google-cast";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface ChromecastConnectionMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onDisconnect?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastConnectionMenu: React.FC<
|
|
||||||
ChromecastConnectionMenuProps
|
|
||||||
> = ({ visible, onClose, onDisconnect }) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const castSession = useCastSession();
|
|
||||||
|
|
||||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
|
||||||
const [displayVolume, setDisplayVolume] = useState(50);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const isMutedRef = useRef(false);
|
|
||||||
const volumeValue = useSharedValue(50);
|
|
||||||
const minimumValue = useSharedValue(0);
|
|
||||||
const maximumValue = useSharedValue(100);
|
|
||||||
const isSliding = useRef(false);
|
|
||||||
const lastSetVolume = useRef(50);
|
|
||||||
|
|
||||||
const protocolColor = "#a855f7";
|
|
||||||
|
|
||||||
// Get initial volume and mute state when menu opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || !castSession) return;
|
|
||||||
|
|
||||||
// Get initial states
|
|
||||||
const fetchInitialState = async () => {
|
|
||||||
try {
|
|
||||||
const vol = await castSession.getVolume();
|
|
||||||
if (vol !== undefined) {
|
|
||||||
const percent = Math.round(vol * 100);
|
|
||||||
setDisplayVolume(percent);
|
|
||||||
volumeValue.value = percent;
|
|
||||||
lastSetVolume.current = percent;
|
|
||||||
}
|
|
||||||
const muted = await castSession.isMute();
|
|
||||||
isMutedRef.current = muted;
|
|
||||||
setIsMuted(muted);
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchInitialState();
|
|
||||||
|
|
||||||
// Poll for external volume changes (physical buttons) - only when not sliding
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (isSliding.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const vol = await castSession.getVolume();
|
|
||||||
if (vol !== undefined) {
|
|
||||||
const percent = Math.round(vol * 100);
|
|
||||||
// Only update if external change detected (not our own change)
|
|
||||||
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
|
||||||
setDisplayVolume(percent);
|
|
||||||
volumeValue.value = percent;
|
|
||||||
lastSetVolume.current = percent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const muted = await castSession.isMute();
|
|
||||||
if (muted !== isMutedRef.current) {
|
|
||||||
isMutedRef.current = muted;
|
|
||||||
setIsMuted(muted);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}, 1000); // Poll less frequently
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [visible, castSession, volumeValue]);
|
|
||||||
|
|
||||||
// Volume change during sliding - update display only, don't call API
|
|
||||||
const handleVolumeChange = useCallback((value: number) => {
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
setDisplayVolume(rounded);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Volume change complete - call API
|
|
||||||
const handleVolumeComplete = useCallback(
|
|
||||||
async (value: number) => {
|
|
||||||
isSliding.current = false;
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
setDisplayVolume(rounded);
|
|
||||||
lastSetVolume.current = rounded;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (castSession) {
|
|
||||||
await castSession.setVolume(rounded / 100);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Connection Menu] Volume error:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[castSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Toggle mute
|
|
||||||
const handleToggleMute = useCallback(async () => {
|
|
||||||
if (!castSession) return;
|
|
||||||
try {
|
|
||||||
const newMute = !isMuted;
|
|
||||||
await castSession.setMute(newMute);
|
|
||||||
isMutedRef.current = newMute;
|
|
||||||
setIsMuted(newMute);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Connection Menu] Mute error:", error);
|
|
||||||
}
|
|
||||||
}, [castSession, isMuted]);
|
|
||||||
|
|
||||||
// Disconnect
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (onDisconnect) {
|
|
||||||
await onDisconnect();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Connection Menu] Disconnect error:", error);
|
|
||||||
} finally {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [onDisconnect, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header with device name */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={20} color='white' />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{castDevice?.friendlyName || t("casting_player.chromecast")}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
|
||||||
{t("casting_player.connected")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Volume Control */}
|
|
||||||
<View style={{ padding: 16 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
|
||||||
{t("casting_player.volume")}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 14 }}>
|
|
||||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: isMuted ? protocolColor : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isMuted ? "volume-mute" : "volume-low"}
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "white" : "#999"}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={volumeValue}
|
|
||||||
minimumValue={minimumValue}
|
|
||||||
maximumValue={maximumValue}
|
|
||||||
theme={{
|
|
||||||
disableMinTrackTintColor: "#333",
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
|
||||||
bubbleBackgroundColor: protocolColor,
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
isSliding.current = true;
|
|
||||||
}}
|
|
||||||
onValueChange={async (value) => {
|
|
||||||
volumeValue.value = value;
|
|
||||||
handleVolumeChange(value);
|
|
||||||
// Unmute when adjusting volume - use ref to avoid
|
|
||||||
// stale closure and prevent repeated async calls
|
|
||||||
if (isMutedRef.current) {
|
|
||||||
isMutedRef.current = false;
|
|
||||||
setIsMuted(false);
|
|
||||||
try {
|
|
||||||
await castSession?.setMute(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(
|
|
||||||
"[ChromecastConnectionMenu] Failed to unmute:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
isMutedRef.current = true;
|
|
||||||
setIsMuted(true); // Rollback on failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSlidingComplete={handleVolumeComplete}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name='volume-high'
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "#666" : "#999"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Disconnect button */}
|
|
||||||
<View style={{ paddingHorizontal: 16 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleDisconnect}
|
|
||||||
style={{
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
padding: 14,
|
|
||||||
borderRadius: 8,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='power' size={20} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
{t("casting_player.disconnect")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Device Info Sheet
|
|
||||||
* Shows device details, volume control, and disconnect option
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Modal, Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import { useCastSession } from "react-native-google-cast";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface ChromecastDeviceSheetProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
device: { friendlyName?: string } | null;
|
|
||||||
onDisconnect: () => Promise<void>;
|
|
||||||
volume?: number;
|
|
||||||
onVolumeChange?: (volume: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
device,
|
|
||||||
onDisconnect,
|
|
||||||
volume = 0.5,
|
|
||||||
onVolumeChange,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
|
||||||
const volumeValue = useSharedValue(volume * 100);
|
|
||||||
const minimumValue = useSharedValue(0);
|
|
||||||
const maximumValue = useSharedValue(100);
|
|
||||||
const castSession = useCastSession();
|
|
||||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const isSliding = useRef(false);
|
|
||||||
const lastSetVolume = useRef(Math.round(volume * 100));
|
|
||||||
|
|
||||||
// Sync volume slider with prop changes (updates from physical buttons)
|
|
||||||
// Skip updates while user is actively sliding to avoid overwriting drag
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSliding.current) return;
|
|
||||||
volumeValue.value = volume * 100;
|
|
||||||
setDisplayVolume(Math.round(volume * 100));
|
|
||||||
}, [volume, volumeValue]);
|
|
||||||
|
|
||||||
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || !castSession) return;
|
|
||||||
|
|
||||||
// Get initial mute state
|
|
||||||
castSession
|
|
||||||
.isMute()
|
|
||||||
.then(setIsMuted)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
// Poll CastSession for device volume and mute state (only when not sliding)
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (isSliding.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deviceVolume = await castSession.getVolume();
|
|
||||||
if (deviceVolume !== undefined) {
|
|
||||||
const volumePercent = Math.round(deviceVolume * 100);
|
|
||||||
// Only update if external change (physical buttons)
|
|
||||||
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
|
||||||
setDisplayVolume(volumePercent);
|
|
||||||
volumeValue.value = volumePercent;
|
|
||||||
lastSetVolume.current = volumePercent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check mute state
|
|
||||||
const muteState = await castSession.isMute();
|
|
||||||
setIsMuted(muteState);
|
|
||||||
} catch {
|
|
||||||
// Ignore errors - device might be disconnected
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [visible, castSession, volumeValue]);
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
try {
|
|
||||||
await onDisconnect();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect:", error);
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeComplete = async (value: number) => {
|
|
||||||
const newVolume = value / 100;
|
|
||||||
setDisplayVolume(Math.round(value));
|
|
||||||
try {
|
|
||||||
// Use CastSession.setVolume for DEVICE volume control
|
|
||||||
// This works even when no media is playing, unlike setStreamVolume
|
|
||||||
if (castSession) {
|
|
||||||
await castSession.setVolume(newVolume);
|
|
||||||
} else if (onVolumeChange) {
|
|
||||||
// Fallback to prop method if session not available
|
|
||||||
await onVolumeChange(newVolume);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Volume] Error setting volume:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Debounced volume update during sliding for smooth live feedback
|
|
||||||
const handleVolumeChange = useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
setDisplayVolume(Math.round(value));
|
|
||||||
|
|
||||||
// Debounce the API call to avoid too many requests
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeDebounceRef.current = setTimeout(async () => {
|
|
||||||
const newVolume = value / 100;
|
|
||||||
try {
|
|
||||||
if (castSession) {
|
|
||||||
await castSession.setVolume(newVolume);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors during sliding
|
|
||||||
}
|
|
||||||
}, 150); // 150ms debounce
|
|
||||||
},
|
|
||||||
[castSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Toggle mute state
|
|
||||||
const handleToggleMute = useCallback(async () => {
|
|
||||||
if (!castSession) return;
|
|
||||||
try {
|
|
||||||
const newMuteState = !isMuted;
|
|
||||||
await castSession.setMute(newMuteState);
|
|
||||||
setIsMuted(newMuteState);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Volume] Error toggling mute:", error);
|
|
||||||
}
|
|
||||||
}, [castSession, isMuted]);
|
|
||||||
|
|
||||||
// Cleanup debounce timer on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{t("casting_player.chromecast")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Device info */}
|
|
||||||
<View style={{ padding: 16 }}>
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
|
||||||
{t("casting_player.device_name")}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
{device?.friendlyName || t("casting_player.unknown_device")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{/* Volume control */}
|
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
|
||||||
{t("casting_player.volume")}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 14 }}>
|
|
||||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Mute button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isMuted ? "volume-mute" : "volume-low"}
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "white" : "#999"}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={volumeValue}
|
|
||||||
minimumValue={minimumValue}
|
|
||||||
maximumValue={maximumValue}
|
|
||||||
theme={{
|
|
||||||
disableMinTrackTintColor: "#333",
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
|
||||||
bubbleBackgroundColor: "#a855f7",
|
|
||||||
}}
|
|
||||||
onSlidingStart={async () => {
|
|
||||||
isSliding.current = true;
|
|
||||||
// Auto-unmute when user starts adjusting volume
|
|
||||||
if (isMuted && castSession) {
|
|
||||||
setIsMuted(false);
|
|
||||||
try {
|
|
||||||
await castSession.setMute(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Volume] Failed to unmute:", error);
|
|
||||||
setIsMuted(true); // Rollback on failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
volumeValue.value = value;
|
|
||||||
handleVolumeChange(value);
|
|
||||||
}}
|
|
||||||
onSlidingComplete={(value) => {
|
|
||||||
isSliding.current = false;
|
|
||||||
lastSetVolume.current = Math.round(value);
|
|
||||||
handleVolumeComplete(value);
|
|
||||||
}}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name='volume-high'
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "#666" : "#999"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Disconnect button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleDisconnect}
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#a855f7",
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
opacity: isDisconnecting ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='power'
|
|
||||||
size={20}
|
|
||||||
color='white'
|
|
||||||
style={{ marginTop: 2 }}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{isDisconnecting
|
|
||||||
? t("casting_player.disconnecting")
|
|
||||||
: t("casting_player.stop_casting")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
/**
|
|
||||||
* Episode List for Chromecast Player
|
|
||||||
* Displays list of episodes for TV shows with thumbnails
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { truncateTitle } from "@/utils/casting/helpers";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
interface ChromecastEpisodeListProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
episodes: BaseItemDto[];
|
|
||||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
|
||||||
api: Api | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
currentItem,
|
|
||||||
episodes,
|
|
||||||
onSelectEpisode,
|
|
||||||
api,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
|
||||||
const scrollRetryCountRef = useRef(0);
|
|
||||||
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const MAX_SCROLL_RETRIES = 3;
|
|
||||||
|
|
||||||
// Cleanup pending retry timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
scrollRetryTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
scrollRetryCountRef.current = 0;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get unique seasons from episodes
|
|
||||||
const seasons = useMemo(() => {
|
|
||||||
const seasonSet = new Set<number>();
|
|
||||||
for (const ep of episodes) {
|
|
||||||
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
|
||||||
seasonSet.add(ep.ParentIndexNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(seasonSet).sort((a, b) => a - b);
|
|
||||||
}, [episodes]);
|
|
||||||
|
|
||||||
// Filter episodes by selected season and exclude virtual episodes
|
|
||||||
const filteredEpisodes = useMemo(() => {
|
|
||||||
let eps = episodes;
|
|
||||||
|
|
||||||
// Filter by season if selected
|
|
||||||
if (selectedSeason !== null) {
|
|
||||||
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out virtual episodes (episodes without actual video files)
|
|
||||||
// LocationType === "Virtual" means the episode doesn't have a media file
|
|
||||||
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
|
||||||
|
|
||||||
return eps;
|
|
||||||
}, [episodes, selectedSeason]);
|
|
||||||
|
|
||||||
// Set initial season to current episode's season
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentItem?.ParentIndexNumber !== undefined) {
|
|
||||||
setSelectedSeason(currentItem.ParentIndexNumber);
|
|
||||||
}
|
|
||||||
}, [currentItem]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset retry counter when visibility or data changes
|
|
||||||
scrollRetryCountRef.current = 0;
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible && currentItem && filteredEpisodes.length > 0) {
|
|
||||||
const currentIndex = filteredEpisodes.findIndex(
|
|
||||||
(ep) => ep.Id === currentItem.Id,
|
|
||||||
);
|
|
||||||
if (currentIndex !== -1 && flatListRef.current) {
|
|
||||||
// Delay to ensure FlatList is rendered
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: currentIndex,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5, // Center the item
|
|
||||||
});
|
|
||||||
}, 300);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [visible, currentItem, filteredEpisodes]);
|
|
||||||
|
|
||||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
|
||||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSelectEpisode(item);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
padding: 12,
|
|
||||||
// Translucent (not solid) purple so the dark base shows through and
|
|
||||||
// the row's text — incl. the purple S:E label — stays readable. The
|
|
||||||
// play-circle icon also marks the current episode.
|
|
||||||
backgroundColor: isCurrentEpisode
|
|
||||||
? "rgba(168, 85, 247, 0.25)"
|
|
||||||
: "transparent",
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 68,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const imageUrl =
|
|
||||||
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
|
|
||||||
if (imageUrl) {
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='film-outline' size={32} color='#333' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode info */}
|
|
||||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
|
|
||||||
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
|
|
||||||
</Text>
|
|
||||||
{item.Overview && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 12,
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
|
||||||
{item.ParentIndexNumber !== undefined &&
|
|
||||||
item.IndexNumber !== undefined && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
|
||||||
{String(item.IndexNumber).padStart(2, "0")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.ProductionYear && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.RunTimeTicks && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{Math.round(item.RunTimeTicks / 600000000)}{" "}
|
|
||||||
{t("casting_player.minutes_short")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isCurrentEpisode && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-circle' size={24} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: insets.top,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: seasons.length > 1 ? 12 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
{t("casting_player.episodes")}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Season selector */}
|
|
||||||
{seasons.length > 1 && (
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ gap: 8 }}
|
|
||||||
>
|
|
||||||
{seasons.map((season) => (
|
|
||||||
<Pressable
|
|
||||||
key={season}
|
|
||||||
onPress={() => setSelectedSeason(season)}
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: selectedSeason === season ? "600" : "400",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("casting_player.season", { number: season })}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<FlatList
|
|
||||||
ref={flatListRef}
|
|
||||||
data={filteredEpisodes}
|
|
||||||
renderItem={renderEpisode}
|
|
||||||
keyExtractor={(item, index) => item.Id || `episode-${index}`}
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
onScrollToIndexFailed={(info) => {
|
|
||||||
// Bounded retry for scroll failures
|
|
||||||
if (
|
|
||||||
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
|
|
||||||
info.index >= filteredEpisodes.length
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollRetryCountRef.current += 1;
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
scrollRetryTimeoutRef.current = setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: info.index,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5,
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Settings Menu
|
|
||||||
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
|
|
||||||
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
export interface VersionOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QualityOption {
|
|
||||||
key: string;
|
|
||||||
value: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChromecastSettingsMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
versions: VersionOption[];
|
|
||||||
selectedVersionId: string;
|
|
||||||
onVersionChange: (id: string) => void;
|
|
||||||
qualities: QualityOption[];
|
|
||||||
selectedMaxBitrate: number | undefined;
|
|
||||||
onQualityChange: (value: number | undefined) => void;
|
|
||||||
audioTracks: AudioTrack[];
|
|
||||||
selectedAudioIndex: number;
|
|
||||||
onAudioChange: (index: number) => void;
|
|
||||||
subtitleTracks: SubtitleTrack[];
|
|
||||||
/** -1 = subtitles off. */
|
|
||||||
selectedSubtitleIndex: number;
|
|
||||||
onSubtitleChange: (index: number) => void;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onPlaybackSpeedChange: (speed: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
||||||
const ACCENT = "#a855f7";
|
|
||||||
|
|
||||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
versions,
|
|
||||||
selectedVersionId,
|
|
||||||
onVersionChange,
|
|
||||||
qualities,
|
|
||||||
selectedMaxBitrate,
|
|
||||||
onQualityChange,
|
|
||||||
audioTracks,
|
|
||||||
selectedAudioIndex,
|
|
||||||
onAudioChange,
|
|
||||||
subtitleTracks,
|
|
||||||
selectedSubtitleIndex,
|
|
||||||
onSubtitleChange,
|
|
||||||
playbackSpeed,
|
|
||||||
onPlaybackSpeedChange,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
|
||||||
setExpandedSection(expandedSection === section ? null : section);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSectionHeader = (
|
|
||||||
title: string,
|
|
||||||
icon: keyof typeof Ionicons.glyphMap,
|
|
||||||
sectionKey: string,
|
|
||||||
) => (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => toggleSection(sectionKey)}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
||||||
<Ionicons name={icon} size={20} color='white' />
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
|
||||||
size={20}
|
|
||||||
color='#999'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (
|
|
||||||
key: string | number,
|
|
||||||
label: string,
|
|
||||||
sublabel: string | null,
|
|
||||||
selected: boolean,
|
|
||||||
onPress: () => void,
|
|
||||||
) => (
|
|
||||||
<Pressable
|
|
||||||
key={key}
|
|
||||||
onPress={() => {
|
|
||||||
onPress();
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor: selected ? "#2a2a2a" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
|
|
||||||
{sublabel ? (
|
|
||||||
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
|
|
||||||
{sublabel}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
maxHeight: "80%",
|
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
{t("casting_player.playback_settings")}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView>
|
|
||||||
{/* Version — only when the item has more than one MediaSource */}
|
|
||||||
{versions.length > 1 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.version"),
|
|
||||||
"albums-outline",
|
|
||||||
"version",
|
|
||||||
)}
|
|
||||||
{versions.length > 1 && expandedSection === "version" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{versions.map((v) =>
|
|
||||||
renderRow(
|
|
||||||
v.id,
|
|
||||||
v.name,
|
|
||||||
null,
|
|
||||||
v.id === selectedVersionId,
|
|
||||||
() => onVersionChange(v.id),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality (bitrate cap) */}
|
|
||||||
{renderSectionHeader(
|
|
||||||
t("casting_player.quality"),
|
|
||||||
"film-outline",
|
|
||||||
"quality",
|
|
||||||
)}
|
|
||||||
{expandedSection === "quality" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{qualities.map((q) =>
|
|
||||||
renderRow(
|
|
||||||
q.key,
|
|
||||||
q.key,
|
|
||||||
null,
|
|
||||||
q.value === selectedMaxBitrate,
|
|
||||||
() => onQualityChange(q.value),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio — only when more than one track */}
|
|
||||||
{audioTracks.length > 1 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.audio"),
|
|
||||||
"musical-notes",
|
|
||||||
"audio",
|
|
||||||
)}
|
|
||||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{audioTracks.map((track) =>
|
|
||||||
renderRow(
|
|
||||||
track.index,
|
|
||||||
track.displayTitle ||
|
|
||||||
track.language ||
|
|
||||||
t("casting_player.unknown"),
|
|
||||||
track.codec ? track.codec.toUpperCase() : null,
|
|
||||||
track.index === selectedAudioIndex,
|
|
||||||
() => onAudioChange(track.index),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitles */}
|
|
||||||
{subtitleTracks.length > 0 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.subtitles"),
|
|
||||||
"text",
|
|
||||||
"subtitles",
|
|
||||||
)}
|
|
||||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{renderRow(
|
|
||||||
"off",
|
|
||||||
t("casting_player.none"),
|
|
||||||
null,
|
|
||||||
selectedSubtitleIndex < 0,
|
|
||||||
() => onSubtitleChange(-1),
|
|
||||||
)}
|
|
||||||
{subtitleTracks.map((track) =>
|
|
||||||
renderRow(
|
|
||||||
track.index,
|
|
||||||
track.displayTitle ||
|
|
||||||
track.language ||
|
|
||||||
t("casting_player.unknown"),
|
|
||||||
[
|
|
||||||
track.codec ? track.codec.toUpperCase() : "",
|
|
||||||
track.isForced ? t("casting_player.forced") : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" • ") || null,
|
|
||||||
track.index === selectedSubtitleIndex,
|
|
||||||
() => onSubtitleChange(track.index),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playback speed */}
|
|
||||||
{renderSectionHeader(
|
|
||||||
t("casting_player.playback_speed"),
|
|
||||||
"speedometer",
|
|
||||||
"speed",
|
|
||||||
)}
|
|
||||||
{expandedSection === "speed" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{PLAYBACK_SPEEDS.map((speed) =>
|
|
||||||
renderRow(
|
|
||||||
speed,
|
|
||||||
speed === 1 ? t("casting_player.normal") : `${speed}x`,
|
|
||||||
null,
|
|
||||||
Math.abs(playbackSpeed - speed) < 0.01,
|
|
||||||
() => onPlaybackSpeedChange(speed),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
|
||||||
* Integrates with autoskip API for segment detection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { isWithinSegment } from "@/utils/casting/helpers";
|
|
||||||
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
|
||||||
import { useSegments } from "@/utils/segments";
|
|
||||||
|
|
||||||
export const useChromecastSegments = (
|
|
||||||
item: BaseItemDto | null,
|
|
||||||
currentProgressMs: number,
|
|
||||||
isOffline = false,
|
|
||||||
) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Fetch segments from autoskip API
|
|
||||||
const { data: segmentData } = useSegments(
|
|
||||||
item?.Id || "",
|
|
||||||
isOffline,
|
|
||||||
undefined, // downloadedFiles parameter
|
|
||||||
api,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse segments into usable format
|
|
||||||
const segments = useMemo<ChromecastSegmentData>(() => {
|
|
||||||
if (!segmentData) {
|
|
||||||
return {
|
|
||||||
intro: null,
|
|
||||||
credits: null,
|
|
||||||
recap: null,
|
|
||||||
commercial: [],
|
|
||||||
preview: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const intro =
|
|
||||||
segmentData.introSegments && segmentData.introSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.introSegments[0].startTime,
|
|
||||||
end: segmentData.introSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const credits =
|
|
||||||
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.creditSegments[0].startTime,
|
|
||||||
end: segmentData.creditSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const recap =
|
|
||||||
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.recapSegments[0].startTime,
|
|
||||||
end: segmentData.recapSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
|
||||||
start: seg.startTime,
|
|
||||||
end: seg.endTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
|
||||||
start: seg.startTime,
|
|
||||||
end: seg.endTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { intro, credits, recap, commercial, preview };
|
|
||||||
}, [segmentData]);
|
|
||||||
|
|
||||||
// Check which segment we're currently in
|
|
||||||
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
|
|
||||||
// before comparing with segment times (which are in seconds from the autoskip API)
|
|
||||||
const currentSegment = useMemo(() => {
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
|
||||||
return { type: "intro" as const, segment: segments.intro };
|
|
||||||
}
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
|
||||||
return { type: "credits" as const, segment: segments.credits };
|
|
||||||
}
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
|
||||||
return { type: "recap" as const, segment: segments.recap };
|
|
||||||
}
|
|
||||||
for (const commercial of segments.commercial) {
|
|
||||||
if (isWithinSegment(currentProgressMs, commercial)) {
|
|
||||||
return { type: "commercial" as const, segment: commercial };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const preview of segments.preview) {
|
|
||||||
if (isWithinSegment(currentProgressMs, preview)) {
|
|
||||||
return { type: "preview" as const, segment: preview };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [currentProgressMs, segments]);
|
|
||||||
|
|
||||||
// Skip functions
|
|
||||||
const skipIntro = useCallback(
|
|
||||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
|
||||||
if (segments.intro) {
|
|
||||||
await seekFn(segments.intro.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.intro],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipCredits = useCallback(
|
|
||||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
|
||||||
if (segments.credits) {
|
|
||||||
await seekFn(segments.credits.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.credits],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
|
||||||
if (currentSegment?.segment) {
|
|
||||||
await seekFn(currentSegment.segment.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentSegment],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-skip logic based on settings
|
|
||||||
const shouldAutoSkip = useMemo(() => {
|
|
||||||
if (!currentSegment) return false;
|
|
||||||
|
|
||||||
switch (currentSegment.type) {
|
|
||||||
case "intro":
|
|
||||||
return settings?.skipIntro === "auto";
|
|
||||||
case "credits":
|
|
||||||
return settings?.skipOutro === "auto";
|
|
||||||
case "recap":
|
|
||||||
return settings?.skipRecap === "auto";
|
|
||||||
case "commercial":
|
|
||||||
return settings?.skipCommercial === "auto";
|
|
||||||
case "preview":
|
|
||||||
return settings?.skipPreview === "auto";
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
currentSegment,
|
|
||||||
settings?.skipIntro,
|
|
||||||
settings?.skipOutro,
|
|
||||||
settings?.skipRecap,
|
|
||||||
settings?.skipCommercial,
|
|
||||||
settings?.skipPreview,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
segments,
|
|
||||||
currentSegment,
|
|
||||||
skipIntro,
|
|
||||||
skipCredits,
|
|
||||||
skipSegment,
|
|
||||||
shouldAutoSkip,
|
|
||||||
hasIntro: !!segments.intro,
|
|
||||||
hasCredits: !!segments.credits,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -133,6 +133,7 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
key={`${person.Id}-${idx}`}
|
key={person.Id}
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
actorId={person.Id}
|
actorId={person.Id}
|
||||||
actorName={person.Name}
|
actorName={person.Name}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Player-agnostic "next episode" countdown card. The parent owns the timer and
|
|
||||||
* positioning — this component only renders the next episode's poster, title,
|
|
||||||
* the remaining seconds, and the Play-now / Cancel actions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface AutoplayCountdownProps {
|
|
||||||
/** The episode that will play next. */
|
|
||||||
nextEpisode: BaseItemDto;
|
|
||||||
/** Poster image URL for the next episode, or null. */
|
|
||||||
posterUrl: string | null;
|
|
||||||
/** Seconds left before the next episode plays. */
|
|
||||||
secondsRemaining: number;
|
|
||||||
/** Play the next episode immediately. */
|
|
||||||
onPlayNow: () => void;
|
|
||||||
/** Cancel autoplay — the next episode will not play. */
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AutoplayCountdown({
|
|
||||||
nextEpisode,
|
|
||||||
posterUrl,
|
|
||||||
secondsRemaining,
|
|
||||||
onPlayNow,
|
|
||||||
onCancel,
|
|
||||||
}: AutoplayCountdownProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 12,
|
|
||||||
width: 320,
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: "rgba(20, 20, 20, 0.94)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{posterUrl && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{ width: 62, height: 93, borderRadius: 6 }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View style={{ flex: 1, justifyContent: "space-between" }}>
|
|
||||||
<View style={{ gap: 2 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
|
||||||
{t("player.up_next")}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{nextEpisode.Name}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#a855f7", fontSize: 13 }}>
|
|
||||||
{t("player.next_episode_in", { seconds: secondsRemaining })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={onPlayNow}
|
|
||||||
accessibilityRole='button'
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: "#a855f7",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
|
||||||
{t("player.play_now")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={onCancel}
|
|
||||||
accessibilityRole='button'
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: "#333",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
|
||||||
{t("player.cancel")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasMovies = movieResults && movieResults.length > 0;
|
||||||
|
const hasTv = tvResults && tvResults.length > 0;
|
||||||
|
const hasPersons = personResults && personResults.length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
|
||||||
keeps focus while typing, otherwise the first result would re-grab
|
|
||||||
focus on every keystroke as results re-render. The user navigates
|
|
||||||
down to the grid manually. */}
|
|
||||||
<TVJellyseerrMovieSection
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={hasMovies}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && hasTv}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
module). It renders the native search bar + grid keyboard and
|
module). It renders the native search bar + grid keyboard and
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
our own results grid renders below. */}
|
our own results grid renders below. */}
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
|
||||||
the native view span the full width and own its own insets. */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
height: SEARCH_AREA_HEIGHT,
|
height: SEARCH_AREA_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section) => (
|
{sections.map((section, index) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
// Never auto-focus a result. The native search field owns focus
|
isFirstSection={index === 0}
|
||||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
|
||||||
// every keystroke as results re-render. User navigates down to the
|
|
||||||
// grid manually.
|
|
||||||
isFirstSection={false}
|
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
contentInset={{
|
||||||
// contentOffset only applies on initial mount; since this FlatList is
|
left: edgePadding,
|
||||||
// reused across searches (stable key), a second search left the inset
|
right: edgePadding,
|
||||||
// without the offset and the grid snapped flush to the left edge.
|
}}
|
||||||
|
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: edgePadding,
|
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,62 +1,18 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Switch, View } from "react-native";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { PlatformDropdown } from "../PlatformDropdown";
|
|
||||||
|
|
||||||
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
|
|
||||||
auto: "Automatic (recommended)",
|
|
||||||
"force-hevc": "Force HEVC / H265",
|
|
||||||
"force-h264": "Force H264",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
|
||||||
const profileOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: (
|
|
||||||
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
|
|
||||||
).map((mode) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: PROFILE_LABELS[mode],
|
|
||||||
value: mode,
|
|
||||||
selected: (settings.chromecastProfile ?? "auto") === mode,
|
|
||||||
onPress: () => updateSettings({ chromecastProfile: mode }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings.chromecastProfile, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
title={"Profile"}
|
<Switch
|
||||||
subtitle={
|
value={settings.enableH265ForChromecast}
|
||||||
"Automatic picks codecs per device. Override only if needed."
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
}
|
updateSettings({ enableH265ForChromecast })
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={profileOptions}
|
|
||||||
title={"Chromecast profile"}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -98,48 +96,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
|
|
||||||
// valid selection even if an out-of-range value was stored previously.
|
|
||||||
const autoplayCountdown = Math.min(
|
|
||||||
60,
|
|
||||||
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
|
|
||||||
);
|
|
||||||
const castAutoplayCountdown = Math.min(
|
|
||||||
60,
|
|
||||||
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoplayCountdownOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: String(seconds),
|
|
||||||
value: String(seconds),
|
|
||||||
selected: seconds === autoplayCountdown,
|
|
||||||
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[autoplayCountdown, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const castAutoplayCountdownOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: String(seconds),
|
|
||||||
value: String(seconds),
|
|
||||||
selected: seconds === castAutoplayCountdown,
|
|
||||||
onPress: () =>
|
|
||||||
updateSettings({ castAutoplayCountdownSeconds: seconds }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[castAutoplayCountdown, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const playbackSpeedOptions = useMemo(
|
const playbackSpeedOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -295,57 +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>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={autoplayCountdownOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={castAutoplayCountdownOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{castAutoplayCountdown}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
@@ -366,6 +271,3 @@ const AUTOPLAY_EPISODES_COUNT = (
|
|||||||
{ key: "6", value: 6 },
|
{ key: "6", value: 6 },
|
||||||
{ key: "7", value: 7 },
|
{ key: "7", value: 7 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
|
|
||||||
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
@@ -13,10 +12,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
@@ -37,14 +35,11 @@ interface BottomControlsProps {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipButton: boolean;
|
showSkipButton: boolean;
|
||||||
skipButtonText: string;
|
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
skipCreditButtonText: string;
|
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
skipCredit: () => void;
|
skipCredit: () => void;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
api?: Api | null;
|
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
handleControlsInteraction: () => void;
|
handleControlsInteraction: () => void;
|
||||||
@@ -95,14 +90,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
skipButtonText,
|
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
skipCreditButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
skipCredit,
|
skipCredit,
|
||||||
nextItem,
|
nextItem,
|
||||||
api,
|
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
handleControlsInteraction,
|
handleControlsInteraction,
|
||||||
@@ -133,83 +125,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
);
|
);
|
||||||
const hasChapters = chapterMarkerList.length > 1;
|
const hasChapters = chapterMarkerList.length > 1;
|
||||||
|
|
||||||
// Autoplay overlay: shown under the same condition the old countdown button used.
|
|
||||||
const autoplayAllowed =
|
|
||||||
settings.autoPlayNextEpisode !== false &&
|
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
|
||||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
|
||||||
|
|
||||||
const showNextEpisodeCountdown =
|
|
||||||
autoplayAllowed &&
|
|
||||||
(!nextItem
|
|
||||||
? false
|
|
||||||
: // Show during credits if no content after, OR near end of video
|
|
||||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
|
||||||
remainingTime < 10000);
|
|
||||||
|
|
||||||
const [secondsRemaining, setSecondsRemaining] = useState(
|
|
||||||
settings.autoplayCountdownSeconds,
|
|
||||||
);
|
|
||||||
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
// Keep a stable ref to the autoplay handler so the timer effect does not
|
|
||||||
// restart when the handler identity changes.
|
|
||||||
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
|
|
||||||
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showNextEpisodeCountdown || autoplayCancelled) {
|
|
||||||
// Either the show-condition flipped off OR the user cancelled.
|
|
||||||
// In both cases, stop the running timer immediately so autoplay
|
|
||||||
// can't fire after Cancel was pressed.
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
// Only reset cancellation + seconds when the show-condition itself
|
|
||||||
// flipped off — a fresh credits/end-of-video window then starts a
|
|
||||||
// brand-new countdown. If we got here because autoplayCancelled
|
|
||||||
// just flipped true, keep it true so the countdown stays stopped.
|
|
||||||
if (!showNextEpisodeCountdown) {
|
|
||||||
setAutoplayCancelled(false);
|
|
||||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setSecondsRemaining((prev) => {
|
|
||||||
if (prev <= 1) {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
autoPlayHandlerRef.current();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return prev - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
showNextEpisodeCountdown,
|
|
||||||
autoplayCancelled,
|
|
||||||
settings.autoplayCountdownSeconds,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nextEpisodePosterUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
|
|
||||||
[api, nextItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Current chapter name for the always-visible header label (live playback).
|
// Current chapter name for the always-visible header label (live playback).
|
||||||
const currentChapterName = useMemo(
|
const currentChapterName = useMemo(
|
||||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||||
@@ -287,7 +202,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText={skipButtonText}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -297,15 +212,22 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={skipCreditButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
<AutoplayCountdown
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
nextEpisode={nextItem}
|
settings.autoPlayEpisodeCount <
|
||||||
posterUrl={nextEpisodePosterUrl}
|
settings.maxAutoPlayEpisodeCount.value) && (
|
||||||
secondsRemaining={secondsRemaining}
|
<NextEpisodeCountDownButton
|
||||||
onPlayNow={handleNextEpisodeManual}
|
show={
|
||||||
onCancel={() => setAutoplayCancelled(true)}
|
!nextItem
|
||||||
|
? false
|
||||||
|
: // Show during credits if no content after, OR near end of video
|
||||||
|
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||||
|
remainingTime < 10000
|
||||||
|
}
|
||||||
|
onFinish={handleNextEpisodeAutoPlay}
|
||||||
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -346,126 +317,28 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert milliseconds to seconds for segment comparison
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
useCreditSkipper(
|
||||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
item.Id!,
|
||||||
|
currentTime,
|
||||||
// Wrapper to convert segment skip from seconds to milliseconds
|
seek,
|
||||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
play,
|
||||||
const seekMs = useCallback(
|
offline,
|
||||||
(timeInSeconds: number) => {
|
api,
|
||||||
// Cancel any pending play call to avoid race conditions
|
downloadedFiles,
|
||||||
if (playTimeoutRef.current) {
|
maxMs,
|
||||||
clearTimeout(playTimeoutRef.current);
|
|
||||||
}
|
|
||||||
seek(timeInSeconds * 1000);
|
|
||||||
// Brief delay ensures the seek operation completes before resuming playback
|
|
||||||
// Without this, playback may resume from the old position
|
|
||||||
// Read latest isPlaying from ref to avoid stale closure
|
|
||||||
playTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (playingRef.current) {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
playTimeoutRef.current = null;
|
|
||||||
}, 200);
|
|
||||||
},
|
|
||||||
[seek, play],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use unified segment skipper for all segment types
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine which segment button to show (priority order)
|
|
||||||
// Commercial > Recap > Intro > Preview > Outro
|
|
||||||
const activeSegment = useMemo(() => {
|
|
||||||
if (commercialSkipper.currentSegment)
|
|
||||||
return { type: "Commercial", ...commercialSkipper };
|
|
||||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
|
||||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
|
||||||
if (previewSkipper.currentSegment)
|
|
||||||
return { type: "Preview", ...previewSkipper };
|
|
||||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
commercialSkipper.currentSegment,
|
|
||||||
recapSkipper.currentSegment,
|
|
||||||
introSkipper.currentSegment,
|
|
||||||
previewSkipper.currentSegment,
|
|
||||||
outroSkipper.currentSegment,
|
|
||||||
commercialSkipper,
|
|
||||||
recapSkipper,
|
|
||||||
introSkipper,
|
|
||||||
previewSkipper,
|
|
||||||
outroSkipper,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Legacy compatibility: map to old variable names
|
|
||||||
const showSkipButton = !!(
|
|
||||||
activeSegment &&
|
|
||||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
|
||||||
);
|
|
||||||
const skipIntro = activeSegment?.skipSegment || noop;
|
|
||||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
|
||||||
const skipCredit = outroSkipper.skipSegment || noop;
|
|
||||||
const hasContentAfterCredits =
|
|
||||||
outroSkipper.currentSegment && maxSeconds
|
|
||||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Get button text based on segment type using i18n
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const skipButtonText = activeSegment
|
|
||||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
|
||||||
: t("player.skip_intro");
|
|
||||||
const skipCreditButtonText = t("player.skip_outro");
|
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
if (!item || !settings) {
|
if (!item || !settings) {
|
||||||
@@ -691,14 +564,11 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipButton={showSkipButton}
|
showSkipButton={showSkipButton}
|
||||||
skipButtonText={skipButtonText}
|
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
skipCreditButtonText={skipCreditButtonText}
|
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
skipCredit={skipCredit}
|
skipCredit={skipCredit}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
api={api}
|
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
handleControlsInteraction={handleControlsInteraction}
|
handleControlsInteraction={handleControlsInteraction}
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
type TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||||
|
onFinish?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||||
|
onFinish,
|
||||||
|
onPress,
|
||||||
|
show,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
progress.value = 0;
|
||||||
|
progress.value = withTiming(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
duration: 10000, // 10 seconds
|
||||||
|
easing: Easing.linear,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (finished && onFinish) {
|
||||||
|
runOnJS(onFinish)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(progress);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [show, onFinish]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${progress.value * 100}%`,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (onPress) {
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
|
||||||
|
{...props}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedStyle} />
|
||||||
|
<View className='px-3 py-3'>
|
||||||
|
<Text numberOfLines={1} className='text-center font-bold'>
|
||||||
|
{t("player.next_episode")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NextEpisodeCountDownButton;
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Chromecast Cast Test Matrix
|
|
||||||
|
|
||||||
Manual verification for the device-profile work. Run each row by casting the
|
|
||||||
matching media from the app to a physical Chromecast and recording the result.
|
|
||||||
|
|
||||||
**Test device:** ___________________ (model name as reported by the app)
|
|
||||||
**App build / commit:** ___________________
|
|
||||||
**Date:** ___________________
|
|
||||||
|
|
||||||
## How to run
|
|
||||||
|
|
||||||
1. Pick a library item matching the row's codec / audio / container.
|
|
||||||
2. Cast it. Note whether it direct-plays or transcodes (server logs show
|
|
||||||
`Video is being transcoded` vs `Video is being direct played`).
|
|
||||||
3. Record the load result: OK / 2100 / infinite-loading / other.
|
|
||||||
|
|
||||||
## Matrix
|
|
||||||
|
|
||||||
| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | |
|
|
||||||
| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | |
|
|
||||||
| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | |
|
|
||||||
| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | |
|
|
||||||
| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | |
|
|
||||||
| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | |
|
|
||||||
| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | |
|
|
||||||
| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | |
|
|
||||||
| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | |
|
|
||||||
| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | |
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
- Highest video bitrate that loads reliably on the test device: ___________
|
|
||||||
-> update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in
|
|
||||||
`utils/casting/capabilities.ts` accordingly.
|
|
||||||
- Confirmed cause of issue #1423 (<= 2 Mb/s): ___________
|
|
||||||
- Confirmed cause of the 5.1 crash (#1085): ___________
|
|
||||||
- Cases where downgrade-on-failure retry rescued playback: ___________
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cast autoplay watcher.
|
|
||||||
*
|
|
||||||
* Always-mounted hook: subscribes to the Chromecast `mediaStatus`, captures the
|
|
||||||
* currently-playing episode while playback is active, and on either
|
|
||||||
* (a) playback entering the Outro segment (when `skipOutro !== "auto"`), or
|
|
||||||
* (b) `IDLE + FINISHED` (hard end of media),
|
|
||||||
* starts a cancellable countdown via `castAutoplayAtom` and ultimately loads
|
|
||||||
* the next episode on the cast.
|
|
||||||
*
|
|
||||||
* The countdown atom is driven here; the casting-player overlay reads it.
|
|
||||||
* Cancellation (overlay's Cancel button) sets the atom to `null` externally;
|
|
||||||
* the watcher reacts by clearing its interval and refusing to retrigger for
|
|
||||||
* the same item.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
MediaPlayerIdleReason,
|
|
||||||
MediaPlayerState,
|
|
||||||
useCastDevice,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
|
||||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
|
||||||
import { useSegments } from "@/utils/segments";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cached next-episode resolution, keyed by the captured (seriesId, currentEpisodeId)
|
|
||||||
* pair so the network calls are not repeated on every `mediaStatus` tick.
|
|
||||||
*/
|
|
||||||
interface NextEpisodeCache {
|
|
||||||
seriesId: string;
|
|
||||||
currentEpisodeId: string;
|
|
||||||
nextEpisode: BaseItemDto | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShouldStartCountdownParams {
|
|
||||||
playerState: MediaPlayerState | undefined;
|
|
||||||
idleReason: MediaPlayerIdleReason | undefined;
|
|
||||||
currentPositionMs: number;
|
|
||||||
outroStartMs: number | null;
|
|
||||||
outroEndMs: number | null;
|
|
||||||
skipOutro: string;
|
|
||||||
alreadyTriggered: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure decision helper: should the countdown start *right now*?
|
|
||||||
* Exported for testability.
|
|
||||||
*/
|
|
||||||
export const shouldStartCountdown = ({
|
|
||||||
playerState,
|
|
||||||
idleReason,
|
|
||||||
currentPositionMs,
|
|
||||||
outroStartMs,
|
|
||||||
outroEndMs,
|
|
||||||
skipOutro,
|
|
||||||
alreadyTriggered,
|
|
||||||
}: ShouldStartCountdownParams): boolean => {
|
|
||||||
if (alreadyTriggered) return false;
|
|
||||||
|
|
||||||
// (b) hard end of media — fires regardless of segment availability.
|
|
||||||
if (
|
|
||||||
playerState === MediaPlayerState.IDLE &&
|
|
||||||
idleReason === MediaPlayerIdleReason.FINISHED
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (a) playback inside Outro segment, and Outro is not already auto-skipped.
|
|
||||||
if (
|
|
||||||
skipOutro !== "auto" &&
|
|
||||||
outroStartMs != null &&
|
|
||||||
outroEndMs != null &&
|
|
||||||
currentPositionMs >= outroStartMs &&
|
|
||||||
currentPositionMs < outroEndMs
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCastAutoplay = (): void => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
const remoteMediaClient = useRemoteMediaClient();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
|
|
||||||
const [autoplayState, setAutoplayState] = useAtom(castAutoplayAtom);
|
|
||||||
|
|
||||||
// Continuously captured currently-playing item (full BaseItemDto, fetched
|
|
||||||
// from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays.
|
|
||||||
const capturedItemRef = useRef<BaseItemDto | null>(null);
|
|
||||||
const capturedItemIdRef = useRef<string | null>(null);
|
|
||||||
// State mirror of the captured item id so downstream effects/hooks re-run
|
|
||||||
// *after* the async getItem resolves — depending on `contentId` directly
|
|
||||||
// would fire them before the ref is populated and they'd read stale data.
|
|
||||||
const [capturedItemId, setCapturedItemId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Cached next-episode resolution per (seriesId, currentEpisodeId).
|
|
||||||
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(null);
|
|
||||||
|
|
||||||
// Last item id we triggered a countdown for. Reset when captured item changes
|
|
||||||
// so the same finished episode does not retrigger.
|
|
||||||
const triggeredForItemIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Countdown interval handle.
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Track whether the atom transitioned to null while a countdown is running —
|
|
||||||
// that means the overlay cancelled, so we must not retrigger for this item.
|
|
||||||
const autoplayStateRef = useRef(autoplayState);
|
|
||||||
autoplayStateRef.current = autoplayState;
|
|
||||||
|
|
||||||
// Latest settings snapshot reachable from the interval / load callback
|
|
||||||
// without re-creating the interval on every settings change.
|
|
||||||
const settingsRef = useRef(settings);
|
|
||||||
settingsRef.current = settings;
|
|
||||||
|
|
||||||
const updateSettingsRef = useRef(updateSettings);
|
|
||||||
updateSettingsRef.current = updateSettings;
|
|
||||||
|
|
||||||
const apiRef = useRef(api);
|
|
||||||
apiRef.current = api;
|
|
||||||
const userRef = useRef(user);
|
|
||||||
userRef.current = user;
|
|
||||||
const remoteMediaClientRef = useRef(remoteMediaClient);
|
|
||||||
remoteMediaClientRef.current = remoteMediaClient;
|
|
||||||
const castDeviceRef = useRef(castDevice);
|
|
||||||
castDeviceRef.current = castDevice;
|
|
||||||
|
|
||||||
const contentId = mediaStatus?.mediaInfo?.contentId ?? null;
|
|
||||||
|
|
||||||
// --- 1. Capture the currently-playing item, full BaseItemDto. ---
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contentId || !api || !user?.Id) {
|
|
||||||
// No active content: clear all captured state so downstream effects /
|
|
||||||
// useSegments stop using a stale previous-item id.
|
|
||||||
capturedItemRef.current = null;
|
|
||||||
capturedItemIdRef.current = null;
|
|
||||||
setCapturedItemId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the captured id changed, reset the trigger guard immediately — the
|
|
||||||
// user moved to another episode, and that new episode should be eligible.
|
|
||||||
if (capturedItemIdRef.current !== contentId) {
|
|
||||||
triggeredForItemIdRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await getUserLibraryApi(api).getItem(
|
|
||||||
{ itemId: contentId, userId: user.Id! },
|
|
||||||
{ signal: controller.signal },
|
|
||||||
);
|
|
||||||
if (cancelled) return;
|
|
||||||
capturedItemRef.current = res.data;
|
|
||||||
capturedItemIdRef.current = contentId;
|
|
||||||
// Publish the captured id as state *after* the ref is set, so the
|
|
||||||
// next-episode-resolve effect (keyed on this state) sees a populated
|
|
||||||
// ref by the time it runs.
|
|
||||||
setCapturedItemId(contentId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError")
|
|
||||||
return;
|
|
||||||
// Non-fatal: keep whatever we last captured.
|
|
||||||
console.error("[useCastAutoplay] Failed to fetch item:", error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [contentId, api, user?.Id]);
|
|
||||||
|
|
||||||
// --- 2. Resolve next episode (cached per series+episode). ---
|
|
||||||
// This effect runs whenever the captured item id changes; the cache key
|
|
||||||
// prevents refetching on every mediaStatus tick.
|
|
||||||
useEffect(() => {
|
|
||||||
const item = capturedItemRef.current;
|
|
||||||
if (!item || !api || !user) return;
|
|
||||||
if (item.Type !== "Episode") {
|
|
||||||
nextEpisodeCacheRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const seriesId = item.SeriesId;
|
|
||||||
const currentEpisodeId = item.Id;
|
|
||||||
if (!seriesId || !currentEpisodeId) {
|
|
||||||
nextEpisodeCacheRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = nextEpisodeCacheRef.current;
|
|
||||||
if (
|
|
||||||
cached &&
|
|
||||||
cached.seriesId === seriesId &&
|
|
||||||
cached.currentEpisodeId === currentEpisodeId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const episodes = await fetchSeriesEpisodes(api, user, seriesId);
|
|
||||||
if (cancelled) return;
|
|
||||||
nextEpisodeCacheRef.current = {
|
|
||||||
seriesId,
|
|
||||||
currentEpisodeId,
|
|
||||||
nextEpisode: findNextEpisode(episodes, currentEpisodeId),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[useCastAutoplay] Failed to resolve next episode:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
// Depend on the *state* mirror of the captured id rather than `contentId`
|
|
||||||
// directly: `contentId` flips synchronously on the new episode, but
|
|
||||||
// `capturedItemRef.current` is only populated after the async getItem
|
|
||||||
// resolves. Keying on `capturedItemId` (set right after the ref write)
|
|
||||||
// guarantees the ref points at the new item by the time we read it here.
|
|
||||||
}, [capturedItemId, api, user]);
|
|
||||||
|
|
||||||
// --- 3. Media segments for the captured item (Outro). ---
|
|
||||||
// Matches `useChromecastSegments`: cast playback is online, no downloaded
|
|
||||||
// files context to thread through.
|
|
||||||
const { data: segmentData } = useSegments(
|
|
||||||
capturedItemId ?? "",
|
|
||||||
false,
|
|
||||||
undefined,
|
|
||||||
api,
|
|
||||||
);
|
|
||||||
|
|
||||||
const outroSegment = segmentData?.creditSegments?.[0] ?? null;
|
|
||||||
const outroStartMs = outroSegment ? outroSegment.startTime * 1000 : null;
|
|
||||||
const outroEndMs = outroSegment ? outroSegment.endTime * 1000 : null;
|
|
||||||
|
|
||||||
// --- 4. Trigger detection. ---
|
|
||||||
useEffect(() => {
|
|
||||||
// Master gate: setting must allow autoplay, and a countdown must not be
|
|
||||||
// already running. The atom drives the countdown; an active atom means
|
|
||||||
// we already triggered (possibly via overlay's Play now).
|
|
||||||
if (!settings.autoPlayNextEpisode) return;
|
|
||||||
if (autoplayState !== null) return;
|
|
||||||
|
|
||||||
const maxValue = settings.maxAutoPlayEpisodeCount.value;
|
|
||||||
if (maxValue !== -1 && settings.autoPlayEpisodeCount >= maxValue) return;
|
|
||||||
|
|
||||||
const capturedItem = capturedItemRef.current;
|
|
||||||
const capturedItemId = capturedItemIdRef.current;
|
|
||||||
if (!capturedItem || !capturedItemId) return;
|
|
||||||
if (capturedItem.Type !== "Episode") return;
|
|
||||||
|
|
||||||
const cached = nextEpisodeCacheRef.current;
|
|
||||||
if (
|
|
||||||
!cached ||
|
|
||||||
cached.currentEpisodeId !== capturedItemId ||
|
|
||||||
!cached.nextEpisode
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextEpisode = cached.nextEpisode;
|
|
||||||
|
|
||||||
const currentPositionMs = (mediaStatus?.streamPosition ?? 0) * 1000;
|
|
||||||
|
|
||||||
const should = shouldStartCountdown({
|
|
||||||
playerState: mediaStatus?.playerState as MediaPlayerState | undefined,
|
|
||||||
idleReason: mediaStatus?.idleReason as MediaPlayerIdleReason | undefined,
|
|
||||||
currentPositionMs,
|
|
||||||
outroStartMs,
|
|
||||||
outroEndMs,
|
|
||||||
skipOutro: settings.skipOutro,
|
|
||||||
alreadyTriggered: triggeredForItemIdRef.current === capturedItemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!should) return;
|
|
||||||
|
|
||||||
triggeredForItemIdRef.current = capturedItemId;
|
|
||||||
setAutoplayState({
|
|
||||||
nextEpisode,
|
|
||||||
secondsRemaining: settings.castAutoplayCountdownSeconds,
|
|
||||||
});
|
|
||||||
// The countdown interval is started by the effect below (reacts to the
|
|
||||||
// atom transitioning to non-null), so this effect stays pure-decide.
|
|
||||||
}, [
|
|
||||||
mediaStatus?.playerState,
|
|
||||||
mediaStatus?.idleReason,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
outroStartMs,
|
|
||||||
outroEndMs,
|
|
||||||
settings.autoPlayNextEpisode,
|
|
||||||
settings.autoPlayEpisodeCount,
|
|
||||||
settings.maxAutoPlayEpisodeCount,
|
|
||||||
settings.castAutoplayCountdownSeconds,
|
|
||||||
settings.skipOutro,
|
|
||||||
autoplayState,
|
|
||||||
setAutoplayState,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// --- 5. Run countdown interval whenever atom is non-null. ---
|
|
||||||
// Starting/stopping is driven by the atom value, so an external Cancel
|
|
||||||
// (overlay) that sets the atom to null naturally tears the interval down.
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoplayState === null) {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only start an interval if one is not already running.
|
|
||||||
if (intervalRef.current) return;
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
// Read latest atom value from ref to decide what to do next.
|
|
||||||
const current = autoplayStateRef.current;
|
|
||||||
if (current === null) {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = current.secondsRemaining - 1;
|
|
||||||
if (next > 0) {
|
|
||||||
setAutoplayState({ ...current, secondsRemaining: next });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time's up — load the next episode and clear.
|
|
||||||
// Snapshot what we need; clear the interval and atom synchronously to
|
|
||||||
// avoid double-fire.
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const episodeToLoad = current.nextEpisode;
|
|
||||||
setAutoplayState(null);
|
|
||||||
|
|
||||||
const apiLocal = apiRef.current;
|
|
||||||
const userLocal = userRef.current;
|
|
||||||
const clientLocal = remoteMediaClientRef.current;
|
|
||||||
const deviceLocal = castDeviceRef.current;
|
|
||||||
const settingsLocal = settingsRef.current;
|
|
||||||
|
|
||||||
if (!apiLocal || !userLocal?.Id || !clientLocal || !episodeToLoad?.Id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror `useCastEpisodes.loadEpisode` exactly — same arguments,
|
|
||||||
// same start-position derivation.
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const startPositionMs =
|
|
||||||
(episodeToLoad.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
|
||||||
|
|
||||||
const result = await loadCastMedia({
|
|
||||||
client: clientLocal,
|
|
||||||
device: deviceLocal,
|
|
||||||
api: apiLocal,
|
|
||||||
item: episodeToLoad,
|
|
||||||
userId: userLocal.Id!,
|
|
||||||
profileMode: settingsLocal.chromecastProfile,
|
|
||||||
maxBitrateSetting: settingsLocal.chromecastMaxBitrate,
|
|
||||||
options: { startPositionMs },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
|
||||||
"[useCastAutoplay] Failed to load next episode:",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the freshest count at the moment of the write — the
|
|
||||||
// overlay's "Play now" can reset this to 0 in parallel, and using
|
|
||||||
// a snapshot taken before the await would clobber that reset.
|
|
||||||
updateSettingsRef.current({
|
|
||||||
autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1,
|
|
||||||
});
|
|
||||||
toast("Playing next episode");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[useCastAutoplay] Failed to load next episode:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [autoplayState, setAutoplayState]);
|
|
||||||
|
|
||||||
// --- 6. Final unmount cleanup is covered by the interval effect's
|
|
||||||
// return; nothing else to do here.
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCastAutoplay;
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { ImperativeRouter } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
|
||||||
import {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withSpring,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
|
|
||||||
interface UseCastDismissGestureParams {
|
|
||||||
router: ImperativeRouter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swipe-down-to-dismiss gesture cluster for the casting player modal.
|
|
||||||
* Owns the `translateY`/`context` shared values, the pan gesture, the animated
|
|
||||||
* style, and the `dismissModal` callback (also invoked by the header button).
|
|
||||||
*/
|
|
||||||
export function useCastDismissGesture({ router }: UseCastDismissGestureParams) {
|
|
||||||
// Swipe down to dismiss gesture
|
|
||||||
const translateY = useSharedValue(0);
|
|
||||||
const context = useSharedValue({ y: 0 });
|
|
||||||
|
|
||||||
const dismissModal = useCallback(() => {
|
|
||||||
// Navigate immediately without animation to prevent crashes
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const panGesture = Gesture.Pan()
|
|
||||||
.onStart(() => {
|
|
||||||
context.value = { y: translateY.value };
|
|
||||||
})
|
|
||||||
.onUpdate((event) => {
|
|
||||||
// Only allow downward swipes from top of screen
|
|
||||||
if (event.translationY > 0) {
|
|
||||||
translateY.value = context.value.y + event.translationY;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
// Dismiss if swiped down more than 150px or fast swipe
|
|
||||||
if (event.translationY > 150 || event.velocityY > 600) {
|
|
||||||
// Animate down and dismiss
|
|
||||||
translateY.value = withSpring(
|
|
||||||
1000,
|
|
||||||
{
|
|
||||||
damping: 20,
|
|
||||||
stiffness: 90,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
runOnJS(dismissModal)();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Spring back to position
|
|
||||||
translateY.value = withSpring(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateY: translateY.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { panGesture, animatedStyle, dismissModal };
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { Device, RemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
|
||||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
|
||||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
|
||||||
|
|
||||||
interface UseCastEpisodesParams {
|
|
||||||
api: Api | null;
|
|
||||||
user: UserDto | null;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
castDevice: Device | null;
|
|
||||||
settings: Settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCastEpisodesResult {
|
|
||||||
episodes: BaseItemDto[];
|
|
||||||
nextEpisode: BaseItemDto | null;
|
|
||||||
seasonData: BaseItemDto | null;
|
|
||||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
|
||||||
/**
|
|
||||||
* Id of the episode currently being loaded onto the cast device, or null
|
|
||||||
* when no load is pending. The cast `customData` (and thus `currentItem`)
|
|
||||||
* lags behind the load, so consumers use this to detect the stale window
|
|
||||||
* between a `loadEpisode` call and the cast reporting the new episode.
|
|
||||||
*/
|
|
||||||
loadingEpisodeId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCastEpisodes({
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
currentItem,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings,
|
|
||||||
}: UseCastEpisodesParams): UseCastEpisodesResult {
|
|
||||||
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
|
||||||
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
|
||||||
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
|
||||||
// Target episode id while a load is in flight; cleared once it resolves.
|
|
||||||
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load a different episode on the Chromecast
|
|
||||||
const loadEpisode = useCallback(
|
|
||||||
async (episode: BaseItemDto) => {
|
|
||||||
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
|
|
||||||
|
|
||||||
setLoadingEpisodeId(episode.Id);
|
|
||||||
try {
|
|
||||||
const startPositionMs =
|
|
||||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
|
||||||
|
|
||||||
const result = await loadCastMedia({
|
|
||||||
client: remoteMediaClient,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
|
||||||
item: episode,
|
|
||||||
userId: user.Id,
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: { startPositionMs },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to load episode:",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Failed to load episode:", error);
|
|
||||||
} finally {
|
|
||||||
// Clear regardless of outcome: on success `currentItem` catches up via
|
|
||||||
// customData; on failure the stale guard must not stay stuck.
|
|
||||||
setLoadingEpisodeId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch season data for season poster
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
currentItem?.Type !== "Episode" ||
|
|
||||||
!currentItem.SeasonId ||
|
|
||||||
!api ||
|
|
||||||
!user?.Id
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const fetchSeasonData = async () => {
|
|
||||||
try {
|
|
||||||
const userLibraryApi = getUserLibraryApi(api);
|
|
||||||
const response = await userLibraryApi.getItem({
|
|
||||||
itemId: currentItem.SeasonId!,
|
|
||||||
userId: user.Id!,
|
|
||||||
});
|
|
||||||
setSeasonData(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Failed to fetch season data:", error);
|
|
||||||
setSeasonData(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSeasonData();
|
|
||||||
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
|
|
||||||
|
|
||||||
// Fetch episodes for TV shows
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
currentItem?.Type !== "Episode" ||
|
|
||||||
!currentItem.SeriesId ||
|
|
||||||
!api ||
|
|
||||||
!user
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const fetchEpisodes = async () => {
|
|
||||||
try {
|
|
||||||
// Fetch ALL episodes from ALL seasons (no season filter).
|
|
||||||
const episodeList = await fetchSeriesEpisodes(
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
currentItem.SeriesId!,
|
|
||||||
);
|
|
||||||
setEpisodes(episodeList);
|
|
||||||
setNextEpisode(findNextEpisode(episodeList, currentItem.Id));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch episodes:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchEpisodes();
|
|
||||||
}, [
|
|
||||||
currentItem?.Type,
|
|
||||||
currentItem?.SeriesId,
|
|
||||||
currentItem?.SeasonId,
|
|
||||||
currentItem?.Id,
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import type { MediaStatus } from "react-native-google-cast";
|
|
||||||
|
|
||||||
interface UseCastPlayerItemParams {
|
|
||||||
api: Api | null;
|
|
||||||
user: UserDto | null;
|
|
||||||
mediaStatus: MediaStatus | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCastPlayerItemResult {
|
|
||||||
fetchedItem: BaseItemDto | null;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCastPlayerItem({
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
mediaStatus,
|
|
||||||
}: UseCastPlayerItemParams): UseCastPlayerItemResult {
|
|
||||||
// Fetch full item data from Jellyfin by ID
|
|
||||||
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const fetchItemData = async () => {
|
|
||||||
const itemId = mediaStatus?.mediaInfo?.contentId;
|
|
||||||
if (!itemId || !api || !user?.Id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await getUserLibraryApi(api).getItem(
|
|
||||||
{ itemId, userId: user.Id },
|
|
||||||
{ signal: controller.signal },
|
|
||||||
);
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setFetchedItem(res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError")
|
|
||||||
return;
|
|
||||||
console.error("[Casting Player] Failed to fetch item:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchItemData();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
|
||||||
|
|
||||||
// Extract item from customData, or use fetched item, or create a minimal fallback
|
|
||||||
const currentItem = useMemo(() => {
|
|
||||||
// Priority 1: Use fetched item from API (most reliable)
|
|
||||||
if (fetchedItem) {
|
|
||||||
return fetchedItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Try customData from mediaStatus
|
|
||||||
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
|
||||||
if (
|
|
||||||
customData?.Type &&
|
|
||||||
(customData.ImageTags || customData.MediaSources || customData.Id)
|
|
||||||
) {
|
|
||||||
// Use customData if it has a real Type AND meaningful metadata
|
|
||||||
// (rules out placeholder objects that lack image tags, media sources, or an ID)
|
|
||||||
return customData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Create minimal fallback while loading
|
|
||||||
if (mediaStatus?.mediaInfo) {
|
|
||||||
const { contentId, metadata } = mediaStatus.mediaInfo;
|
|
||||||
// Derive type from metadata if available, otherwise omit to avoid
|
|
||||||
// misrepresenting episodes as movies
|
|
||||||
let metadataType: string | undefined;
|
|
||||||
if (metadata?.type === "movie") {
|
|
||||||
metadataType = "Movie";
|
|
||||||
} else if (metadata?.type === "tvShow") {
|
|
||||||
metadataType = "Episode";
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
Id: contentId,
|
|
||||||
Name: metadata?.title || "Unknown",
|
|
||||||
...(metadataType ? { Type: metadataType } : {}),
|
|
||||||
ServerId: "",
|
|
||||||
} as BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [fetchedItem, mediaStatus?.mediaInfo]);
|
|
||||||
|
|
||||||
return { fetchedItem, currentItem };
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
|
||||||
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
|
|
||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
|
|
||||||
interface TrickplayTime {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCastPlayerProgressParams {
|
|
||||||
/** Raw Chromecast media status, or null when no session. */
|
|
||||||
mediaStatus: MediaStatus | null;
|
|
||||||
/** Full item fetched from Jellyfin, used to derive trickplay data. */
|
|
||||||
fetchedItem: BaseItemDto | null;
|
|
||||||
/** Total media duration, in seconds. */
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
|
||||||
|
|
||||||
interface UseCastPlayerProgressResult {
|
|
||||||
/** Shared value tracking the slider progress, in milliseconds. */
|
|
||||||
sliderProgress: SharedValue<number>;
|
|
||||||
/** Shared value for the slider minimum, in milliseconds. */
|
|
||||||
sliderMin: SharedValue<number>;
|
|
||||||
/** Shared value for the slider maximum, in milliseconds. */
|
|
||||||
sliderMax: SharedValue<number>;
|
|
||||||
/** Mutable ref flag set true while the user is scrubbing. */
|
|
||||||
isScrubbing: RefObject<boolean>;
|
|
||||||
/** Trickplay time display state for the bubble. */
|
|
||||||
trickplayTime: TrickplayTime;
|
|
||||||
/** Updates the trickplay time display state. */
|
|
||||||
setTrickplayTime: (time: TrickplayTime) => void;
|
|
||||||
/** Current playback progress, in seconds (live-updating). */
|
|
||||||
progress: number;
|
|
||||||
/** Last stable playback position (seconds), for resuming across reloads. */
|
|
||||||
resumePositionRef: RefObject<number>;
|
|
||||||
/** Current trickplay image URL/coordinates, or null. */
|
|
||||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
|
||||||
/** Computes the trickplay URL for a given progress in ticks. */
|
|
||||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
|
||||||
/** Parsed trickplay metadata, or null. */
|
|
||||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Progress/slider/trickplay cluster for the casting player.
|
|
||||||
* Owns the slider shared values, scrub state, live-progress interpolation,
|
|
||||||
* resume-position tracking, and trickplay preview.
|
|
||||||
*/
|
|
||||||
export function useCastPlayerProgress({
|
|
||||||
mediaStatus,
|
|
||||||
fetchedItem,
|
|
||||||
duration,
|
|
||||||
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
|
|
||||||
// Shared values for progress slider (must be initialized before any early returns)
|
|
||||||
const sliderProgress = useSharedValue(0);
|
|
||||||
const sliderMin = useSharedValue(0);
|
|
||||||
const sliderMax = useSharedValue(100);
|
|
||||||
const isScrubbing = useRef(false);
|
|
||||||
|
|
||||||
// Trickplay time display
|
|
||||||
const [trickplayTime, setTrickplayTime] = useState({
|
|
||||||
hours: 0,
|
|
||||||
minutes: 0,
|
|
||||||
seconds: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Live progress tracking - update every second
|
|
||||||
const [liveProgress, setLiveProgress] = useState(0);
|
|
||||||
const lastSyncPositionRef = useRef(0);
|
|
||||||
const lastSyncTimestampRef = useRef(Date.now());
|
|
||||||
|
|
||||||
// Last stable playback position (seconds), for resuming across reloads.
|
|
||||||
const resumePositionRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Sync refs whenever mediaStatus provides a new position
|
|
||||||
if (mediaStatus?.streamPosition !== undefined) {
|
|
||||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
|
||||||
lastSyncTimestampRef.current = Date.now();
|
|
||||||
setLiveProgress(mediaStatus.streamPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update every second when playing, deriving from last sync point
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
|
||||||
mediaStatus?.streamPosition !== undefined
|
|
||||||
) {
|
|
||||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
|
||||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
|
||||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
|
||||||
// Sync with actual position when paused/buffering
|
|
||||||
setLiveProgress(mediaStatus.streamPosition);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
|
||||||
|
|
||||||
// Track the last stable position so a reload mid-switch resumes correctly.
|
|
||||||
useEffect(() => {
|
|
||||||
const pos = mediaStatus?.streamPosition ?? 0;
|
|
||||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
|
||||||
resumePositionRef.current = pos;
|
|
||||||
}
|
|
||||||
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
|
||||||
|
|
||||||
// Derive state from raw Chromecast hooks
|
|
||||||
const progress = liveProgress; // Use live-updating progress
|
|
||||||
|
|
||||||
// Trickplay for seeking preview - use fetched item with full data
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
||||||
fetchedItem ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update slider max when duration changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (duration > 0) {
|
|
||||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
|
||||||
}
|
|
||||||
}, [duration, sliderMax]);
|
|
||||||
|
|
||||||
// Update slider progress when not scrubbing
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isScrubbing.current && progress > 0) {
|
|
||||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
|
||||||
}
|
|
||||||
}, [progress, sliderProgress]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sliderProgress,
|
|
||||||
sliderMin,
|
|
||||||
sliderMax,
|
|
||||||
isScrubbing,
|
|
||||||
trickplayTime,
|
|
||||||
setTrickplayTime,
|
|
||||||
progress,
|
|
||||||
resumePositionRef,
|
|
||||||
trickPlayUrl,
|
|
||||||
calculateTrickplayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Source of truth for the active cast track / quality / version selection.
|
|
||||||
*
|
|
||||||
* Truth = the CastSelection echoed back in the cast media customData. A local
|
|
||||||
* `pending` selection is shown optimistically while a reload re-transcodes, then
|
|
||||||
* cleared once the cast reports it (reconciled) or the reload fails.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { MediaStatus } from "react-native-google-cast";
|
|
||||||
import { resolveSelection, selectionsEqual } from "@/utils/casting/selection";
|
|
||||||
import type { CastSelection } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
interface UseCastSelectionParams {
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
mediaStatus: MediaStatus | null | undefined;
|
|
||||||
/** Reload the cast stream with the given selection. Resolves true on success. */
|
|
||||||
reload: (selection: CastSelection) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCastSelectionResult {
|
|
||||||
/** Effective selection: optimistic pending, else cast truth, else default. */
|
|
||||||
currentSelection: CastSelection | null;
|
|
||||||
/** Merge a partial selection, show it optimistically, and reload the stream. */
|
|
||||||
applySelection: (partial: Partial<CastSelection>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCastSelection = ({
|
|
||||||
currentItem,
|
|
||||||
mediaStatus,
|
|
||||||
reload,
|
|
||||||
}: UseCastSelectionParams): UseCastSelectionResult => {
|
|
||||||
const [pending, setPending] = useState<CastSelection | null>(null);
|
|
||||||
|
|
||||||
// Truth: the selection the cast reports as loaded, via customData.
|
|
||||||
const truth =
|
|
||||||
(
|
|
||||||
mediaStatus?.mediaInfo?.customData as
|
|
||||||
| { selection?: CastSelection }
|
|
||||||
| undefined
|
|
||||||
)?.selection ?? null;
|
|
||||||
|
|
||||||
const currentSelection: CastSelection | null =
|
|
||||||
pending ??
|
|
||||||
truth ??
|
|
||||||
(currentItem ? resolveSelection(currentItem, {}) : null);
|
|
||||||
|
|
||||||
// A new media item invalidates any pending selection from the previous one.
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only
|
|
||||||
useEffect(() => {
|
|
||||||
setPending(null);
|
|
||||||
}, [currentItem?.Id]);
|
|
||||||
|
|
||||||
// Reconcile: once the cast reports the pending selection as loaded, clear it.
|
|
||||||
useEffect(() => {
|
|
||||||
if (pending && truth && selectionsEqual(pending, truth)) {
|
|
||||||
setPending(null);
|
|
||||||
}
|
|
||||||
}, [pending, truth]);
|
|
||||||
|
|
||||||
const applySelection = useCallback(
|
|
||||||
(partial: Partial<CastSelection>) => {
|
|
||||||
if (!currentSelection) return;
|
|
||||||
const next: CastSelection = { ...currentSelection, ...partial };
|
|
||||||
setPending(next);
|
|
||||||
reload(next).then((ok) => {
|
|
||||||
if (!ok) setPending(null);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[currentSelection, reload],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { currentSelection, applySelection };
|
|
||||||
};
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Hook
|
|
||||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
|
||||||
* Architecture allows for future protocol integrations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
CastState,
|
|
||||||
useCastDevice,
|
|
||||||
useCastState,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
|
||||||
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified hook for managing casting
|
|
||||||
* Extensible architecture supporting multiple protocols
|
|
||||||
*/
|
|
||||||
export const useCasting = (item: BaseItemDto | null) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
// Chromecast hooks
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const castState = useCastState();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
|
||||||
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
|
||||||
|
|
||||||
// Helper to update both state and ref
|
|
||||||
const updateState = useCallback(
|
|
||||||
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
|
||||||
setState((prev) => {
|
|
||||||
const next = updater(prev);
|
|
||||||
stateRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
|
|
||||||
const playSessionId =
|
|
||||||
(
|
|
||||||
mediaStatus?.mediaInfo?.customData as
|
|
||||||
| { playSessionId?: string }
|
|
||||||
| undefined
|
|
||||||
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
|
|
||||||
|
|
||||||
const playMethod =
|
|
||||||
(
|
|
||||||
mediaStatus?.mediaInfo?.customData as
|
|
||||||
| { playMethod?: "Transcode" | "DirectPlay" }
|
|
||||||
| undefined
|
|
||||||
)?.playMethod ?? "Transcode";
|
|
||||||
|
|
||||||
// Detect which protocol is active - use CastState for reliable detection
|
|
||||||
const chromecastConnected = castState === CastState.CONNECTED;
|
|
||||||
// Future: Add detection for other protocols here
|
|
||||||
|
|
||||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
|
||||||
? "chromecast"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const isConnected = chromecastConnected;
|
|
||||||
|
|
||||||
// Update current device
|
|
||||||
useEffect(() => {
|
|
||||||
if (chromecastConnected && castDevice) {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isConnected: true,
|
|
||||||
protocol: "chromecast",
|
|
||||||
currentDevice: {
|
|
||||||
id: castDevice.deviceId,
|
|
||||||
name: castDevice.friendlyName || castDevice.deviceId,
|
|
||||||
protocol: "chromecast",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isConnected: false,
|
|
||||||
protocol: null,
|
|
||||||
currentDevice: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Future: Add device detection for other protocols
|
|
||||||
}, [chromecastConnected, castDevice]);
|
|
||||||
|
|
||||||
// Chromecast: Update playback state
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeProtocol === "chromecast" && mediaStatus) {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isPlaying: mediaStatus.playerState === "playing",
|
|
||||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
|
||||||
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
|
||||||
isBuffering: mediaStatus.playerState === "buffering",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mediaStatus, activeProtocol, updateState]);
|
|
||||||
|
|
||||||
// Chromecast: Sync volume from mediaStatus
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeProtocol !== "chromecast") return;
|
|
||||||
|
|
||||||
// Sync from mediaStatus when available
|
|
||||||
if (mediaStatus?.volume !== undefined) {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
volume: mediaStatus.volume,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
|
||||||
|
|
||||||
// Progress reporting to Jellyfin (matches native player behavior)
|
|
||||||
// Uses stateRef to read current progress/volume without adding them as deps
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
|
||||||
|
|
||||||
const playStateApi = getPlaystateApi(api);
|
|
||||||
|
|
||||||
// Report playback start when media begins (only once per item)
|
|
||||||
// Don't require progress > 0 — playback can legitimately start at position 0
|
|
||||||
const currentState = stateRef.current;
|
|
||||||
const isPlaybackActive =
|
|
||||||
currentState.isPlaying ||
|
|
||||||
mediaStatus?.playerState === "playing" ||
|
|
||||||
currentState.progress > 0;
|
|
||||||
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
|
|
||||||
// Set synchronously before async call to prevent race condition duplicates
|
|
||||||
hasReportedStartRef.current = item.Id || null;
|
|
||||||
|
|
||||||
playStateApi
|
|
||||||
.reportPlaybackStart({
|
|
||||||
playbackStartInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: Math.floor(currentState.progress * 10000),
|
|
||||||
PlayMethod: playMethod,
|
|
||||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
|
||||||
IsMuted: currentState.volume === 0,
|
|
||||||
PlaySessionId: playSessionId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
// Revert on failure so it can be retried
|
|
||||||
hasReportedStartRef.current = null;
|
|
||||||
console.error("[useCasting] Failed to report playback start:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportProgress = () => {
|
|
||||||
const s = stateRef.current;
|
|
||||||
// Don't report if no meaningful progress or if buffering
|
|
||||||
if (s.progress <= 0 || s.isBuffering) return;
|
|
||||||
|
|
||||||
const progressMs = Math.floor(s.progress);
|
|
||||||
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
|
||||||
const progressSeconds = Math.floor(progressMs / 1000);
|
|
||||||
|
|
||||||
// When paused, always report to keep server in sync
|
|
||||||
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
|
||||||
if (
|
|
||||||
s.isPlaying &&
|
|
||||||
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastReportedProgressRef.current = progressSeconds;
|
|
||||||
|
|
||||||
playStateApi
|
|
||||||
.reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: progressTicks,
|
|
||||||
IsPaused: !s.isPlaying,
|
|
||||||
PlayMethod: playMethod,
|
|
||||||
VolumeLevel: Math.floor(s.volume * 100),
|
|
||||||
IsMuted: s.volume === 0,
|
|
||||||
PlaySessionId: playSessionId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[useCasting] Failed to report progress:", error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Report progress on a fixed interval, reading latest state from ref
|
|
||||||
const interval = setInterval(reportProgress, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
item?.Id,
|
|
||||||
user?.Id,
|
|
||||||
isConnected,
|
|
||||||
activeProtocol,
|
|
||||||
playSessionId,
|
|
||||||
playMethod,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Play/Pause controls
|
|
||||||
const play = useCallback(async () => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
// Check if there's an active media session
|
|
||||||
if (!client || !mediaStatus?.mediaInfo) {
|
|
||||||
console.warn(
|
|
||||||
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await client.play();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[useCasting] Error playing:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Future: Add play control for other protocols
|
|
||||||
}, [client, mediaStatus, activeProtocol]);
|
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
try {
|
|
||||||
await client?.pause();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[useCasting] Error pausing:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Future: Add pause control for other protocols
|
|
||||||
}, [client, activeProtocol]);
|
|
||||||
|
|
||||||
const togglePlayPause = useCallback(async () => {
|
|
||||||
if (state.isPlaying) {
|
|
||||||
await pause();
|
|
||||||
} else {
|
|
||||||
await play();
|
|
||||||
}
|
|
||||||
}, [state.isPlaying, play, pause]);
|
|
||||||
|
|
||||||
// Seek controls
|
|
||||||
const seek = useCallback(
|
|
||||||
async (positionMs: number) => {
|
|
||||||
// Validate position
|
|
||||||
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
|
||||||
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionSeconds = positionMs / 1000;
|
|
||||||
|
|
||||||
// Additional validation for Chromecast
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
|
||||||
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
|
|
||||||
const durationSeconds = state.duration / 1000;
|
|
||||||
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
|
|
||||||
console.warn(
|
|
||||||
"[useCasting] Seek position exceeds duration, clamping:",
|
|
||||||
positionSeconds,
|
|
||||||
"->",
|
|
||||||
durationSeconds,
|
|
||||||
);
|
|
||||||
await client?.seek({ position: durationSeconds });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await client?.seek({ position: positionSeconds });
|
|
||||||
}
|
|
||||||
// Future: Add seek control for other protocols
|
|
||||||
},
|
|
||||||
[client, activeProtocol, state.duration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipForward = useCallback(
|
|
||||||
async (seconds = 10) => {
|
|
||||||
const newPosition = state.progress + seconds * 1000;
|
|
||||||
await seek(Math.min(newPosition, state.duration));
|
|
||||||
},
|
|
||||||
[state.progress, state.duration, seek],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipBackward = useCallback(
|
|
||||||
async (seconds = 10) => {
|
|
||||||
const newPosition = state.progress - seconds * 1000;
|
|
||||||
await seek(Math.max(newPosition, 0));
|
|
||||||
},
|
|
||||||
[state.progress, seek],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stop and disconnect
|
|
||||||
const stop = useCallback(
|
|
||||||
async (onStopComplete?: () => void) => {
|
|
||||||
try {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
await client?.stop();
|
|
||||||
}
|
|
||||||
// Future: Add stop control for other protocols
|
|
||||||
|
|
||||||
// Report stop to Jellyfin
|
|
||||||
if (api && item?.Id && user?.Id) {
|
|
||||||
const playStateApi = getPlaystateApi(api);
|
|
||||||
await playStateApi.reportPlaybackStopped({
|
|
||||||
playbackStopInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: stateRef.current.progress * 10000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[useCasting] Error during stop:", error);
|
|
||||||
} finally {
|
|
||||||
hasReportedStartRef.current = null;
|
|
||||||
setState(DEFAULT_CAST_STATE);
|
|
||||||
stateRef.current = DEFAULT_CAST_STATE;
|
|
||||||
|
|
||||||
// Call callback after stop completes (e.g., to navigate away)
|
|
||||||
if (onStopComplete) {
|
|
||||||
onStopComplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[client, api, item?.Id, user?.Id, activeProtocol],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Volume control (debounced to reduce API calls)
|
|
||||||
const setVolume = useCallback(
|
|
||||||
(volume: number) => {
|
|
||||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
|
||||||
|
|
||||||
// Update UI immediately
|
|
||||||
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
|
||||||
|
|
||||||
// Debounce API call
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeDebounceRef.current = setTimeout(async () => {
|
|
||||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
|
||||||
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
|
||||||
// Physical volume buttons are handled automatically by the framework
|
|
||||||
await client.setStreamVolume(clampedVolume).catch(() => {
|
|
||||||
// Ignore errors - session might have ended
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Future: Add volume control for other protocols
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
[client, activeProtocol, isConnected],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isConnected,
|
|
||||||
protocol: activeProtocol,
|
|
||||||
isPlaying: state.isPlaying,
|
|
||||||
isBuffering: state.isBuffering,
|
|
||||||
currentItem: item,
|
|
||||||
currentDevice: state.currentDevice,
|
|
||||||
progress: state.progress,
|
|
||||||
duration: state.duration,
|
|
||||||
volume: state.volume,
|
|
||||||
|
|
||||||
// Availability - derived from actual cast state
|
|
||||||
isChromecastAvailable:
|
|
||||||
castState === CastState.CONNECTED ||
|
|
||||||
castState === CastState.CONNECTING ||
|
|
||||||
castState === CastState.NOT_CONNECTED,
|
|
||||||
|
|
||||||
// Raw clients (for advanced operations)
|
|
||||||
remoteMediaClient: client,
|
|
||||||
|
|
||||||
// Controls
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
togglePlayPause,
|
|
||||||
seek,
|
|
||||||
skipForward,
|
|
||||||
skipBackward,
|
|
||||||
stop,
|
|
||||||
setVolume,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -109,35 +109,30 @@ export const usePlaybackManager = ({
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive prev/next from the current item's real position in the adjacent
|
|
||||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
|
||||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
|
||||||
* episode it can still return the current item as the first/last entry — so
|
|
||||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
|
||||||
*/
|
|
||||||
const currentIndex = useMemo(
|
|
||||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
|
||||||
[adjacentItems, item],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** A neighbour is only navigable if it has an actual media file (not a
|
|
||||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
|
||||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
|
||||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
|
||||||
|
|
||||||
const previousItem = useMemo(() => {
|
const previousItem = useMemo(() => {
|
||||||
if (!adjacentItems || currentIndex <= 0) return null;
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
const candidate = adjacentItems[currentIndex - 1];
|
return null;
|
||||||
return isNavigable(candidate) ? candidate : null;
|
}
|
||||||
}, [adjacentItems, currentIndex, item]);
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[0];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
/** The next item in the series */
|
/** The next item in the series */
|
||||||
const nextItem = useMemo(() => {
|
const nextItem = useMemo(() => {
|
||||||
if (!adjacentItems || currentIndex < 0) return null;
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
const candidate = adjacentItems[currentIndex + 1];
|
return null;
|
||||||
return isNavigable(candidate) ? candidate : null;
|
}
|
||||||
}, [adjacentItems, currentIndex, item]);
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports playback progress.
|
* Reports playback progress.
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dispatches Jellyfin remote-control WebSocket messages to the active
|
|
||||||
* PlaybackController. DisplayMessage is shown as an in-app toast and needs no
|
|
||||||
* controller.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
|
|
||||||
import {
|
|
||||||
mapRemoteCommand,
|
|
||||||
type RemoteWsMessage,
|
|
||||||
} from "@/utils/playback/remoteCommands";
|
|
||||||
|
|
||||||
/** Handle one remote-control message (call it whenever a new WS message arrives). */
|
|
||||||
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
|
|
||||||
const controller = useAtomValue(activePlaybackControllerAtom);
|
|
||||||
const handledRef = useRef<RemoteWsMessage | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastMessage || lastMessage === handledRef.current) return;
|
|
||||||
handledRef.current = lastMessage;
|
|
||||||
const action = mapRemoteCommand(lastMessage);
|
|
||||||
if (!action) return;
|
|
||||||
|
|
||||||
if (action.kind === "displayMessage") {
|
|
||||||
toast(action.text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controller) return;
|
|
||||||
|
|
||||||
switch (action.kind) {
|
|
||||||
case "playPause":
|
|
||||||
controller.playPause();
|
|
||||||
break;
|
|
||||||
case "pause":
|
|
||||||
controller.pause();
|
|
||||||
break;
|
|
||||||
case "unpause":
|
|
||||||
controller.unpause();
|
|
||||||
break;
|
|
||||||
case "stop":
|
|
||||||
controller.stop();
|
|
||||||
break;
|
|
||||||
case "seek":
|
|
||||||
controller.seek(action.positionMs);
|
|
||||||
break;
|
|
||||||
case "next":
|
|
||||||
controller.next();
|
|
||||||
break;
|
|
||||||
case "previous":
|
|
||||||
controller.previous();
|
|
||||||
break;
|
|
||||||
case "setVolume":
|
|
||||||
controller.setVolume(action.level);
|
|
||||||
break;
|
|
||||||
case "toggleMute":
|
|
||||||
controller.toggleMute();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [lastMessage, controller]);
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useHaptic } from "./useHaptic";
|
|
||||||
|
|
||||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
|
||||||
|
|
||||||
interface UseSegmentSkipperProps {
|
|
||||||
segments: MediaTimeSegment[];
|
|
||||||
segmentType: SegmentType;
|
|
||||||
currentTime: number;
|
|
||||||
totalDuration?: number;
|
|
||||||
seek: (time: number) => void;
|
|
||||||
isPaused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSegmentSkipperReturn {
|
|
||||||
currentSegment: MediaTimeSegment | null;
|
|
||||||
skipSegment: (notifyOrUseHaptics?: 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);
|
|
||||||
|
|
||||||
// Get skip mode based on segment type
|
|
||||||
const skipMode = (() => {
|
|
||||||
switch (segmentType) {
|
|
||||||
case "Intro":
|
|
||||||
return settings.skipIntro;
|
|
||||||
case "Outro":
|
|
||||||
return settings.skipOutro;
|
|
||||||
case "Recap":
|
|
||||||
return settings.skipRecap;
|
|
||||||
case "Commercial":
|
|
||||||
return settings.skipCommercial;
|
|
||||||
case "Preview":
|
|
||||||
return settings.skipPreview;
|
|
||||||
default:
|
|
||||||
return "none";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Find current segment
|
|
||||||
const currentSegment =
|
|
||||||
segments.find(
|
|
||||||
(segment) =>
|
|
||||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
// Skip function with optional haptic feedback
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
(notifyOrUseHaptics = true) => {
|
|
||||||
if (!currentSegment || skipMode === "none") return;
|
|
||||||
|
|
||||||
// For Outro segments, prevent seeking past the end
|
|
||||||
if (
|
|
||||||
segmentType === "Outro" &&
|
|
||||||
totalDuration != null &&
|
|
||||||
Number.isFinite(totalDuration)
|
|
||||||
) {
|
|
||||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
|
||||||
seek(seekTime);
|
|
||||||
} else {
|
|
||||||
seek(currentSegment.endTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
|
||||||
if (notifyOrUseHaptics) {
|
|
||||||
haptic();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
|
||||||
);
|
|
||||||
// Auto-skip logic when mode is 'auto'
|
|
||||||
useEffect(() => {
|
|
||||||
if (skipMode !== "auto" || isPaused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track segment identity to avoid re-triggering on pause/unpause
|
|
||||||
const segmentId = currentSegment
|
|
||||||
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
|
||||||
autoSkipTriggeredRef.current = segmentId;
|
|
||||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSegment) {
|
|
||||||
autoSkipTriggeredRef.current = null;
|
|
||||||
}
|
|
||||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
|
||||||
|
|
||||||
// Return null segment if skip mode is 'none'
|
|
||||||
return {
|
|
||||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
|
||||||
skipSegment,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -17,24 +17,20 @@ interface TrickplayUrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hook to handle trickplay logic for a given item. */
|
/** Hook to handle trickplay logic for a given item. */
|
||||||
export const useTrickplay = (item: BaseItemDto | null) => {
|
export const useTrickplay = (item: BaseItemDto) => {
|
||||||
const { getDownloadedItemById } = useDownload();
|
const { getDownloadedItemById } = useDownload();
|
||||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||||
const lastCalculationTime = useRef(0);
|
const lastCalculationTime = useRef(0);
|
||||||
const throttleDelay = 200;
|
const throttleDelay = 200;
|
||||||
const isOffline = useGlobalSearchParams().offline === "true";
|
const isOffline = useGlobalSearchParams().offline === "true";
|
||||||
const trickplayInfo = useMemo(
|
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||||
() => (item ? getTrickplayInfo(item) : null),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Generates the trickplay URL for the given item and sheet index.
|
/** Generates the trickplay URL for the given item and sheet index.
|
||||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||||
const getTrickplayUrl = useCallback(
|
const getTrickplayUrl = useCallback(
|
||||||
(item: BaseItemDto, sheetIndex: number) => {
|
(item: BaseItemDto, sheetIndex: number) => {
|
||||||
if (!item.Id) return null;
|
|
||||||
// If we are offline, we can use the downloaded item's trickplay data path
|
// If we are offline, we can use the downloaded item's trickplay data path
|
||||||
const downloadedItem = getDownloadedItemById(item.Id);
|
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||||
}
|
}
|
||||||
@@ -49,7 +45,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
!trickplayInfo ||
|
!trickplayInfo ||
|
||||||
!item?.Id ||
|
!item.Id ||
|
||||||
now - lastCalculationTime.current < throttleDelay
|
now - lastCalculationTime.current < throttleDelay
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -66,7 +62,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
|||||||
|
|
||||||
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
||||||
const prefetchAllTrickplayImages = useCallback(async () => {
|
const prefetchAllTrickplayImages = useCallback(async () => {
|
||||||
if (!trickplayInfo || !item?.Id) return;
|
if (!trickplayInfo || !item.Id) return;
|
||||||
const maxConcurrent = 4;
|
const maxConcurrent = 4;
|
||||||
const total = trickplayInfo.totalImageSheets;
|
const total = trickplayInfo.totalImageSheets;
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
|||||||
import { settingsAtom } from "@/utils/atoms/settings";
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import {
|
|
||||||
type PlaybackController,
|
|
||||||
useRegisterPlaybackController,
|
|
||||||
} from "@/utils/playback/playbackController";
|
|
||||||
|
|
||||||
// Conditionally import TrackPlayer only on non-TV platforms
|
// Conditionally import TrackPlayer only on non-TV platforms
|
||||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||||
@@ -1625,43 +1621,6 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
settings?.audioLookaheadCount,
|
settings?.audioLookaheadCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// App-wide remote-control surface: wraps the existing music controls so
|
|
||||||
// remote commands can target whatever player is currently active.
|
|
||||||
const isMusicActive = state.currentTrack !== null;
|
|
||||||
|
|
||||||
const playbackController = useMemo<PlaybackController>(
|
|
||||||
() => ({
|
|
||||||
playPause: () => {
|
|
||||||
togglePlayPause();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
pause();
|
|
||||||
},
|
|
||||||
unpause: () => {
|
|
||||||
resume();
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
stop();
|
|
||||||
},
|
|
||||||
// TrackPlayer works in seconds; the controller contract is milliseconds.
|
|
||||||
seek: (positionMs: number) => {
|
|
||||||
seek(positionMs / 1000);
|
|
||||||
},
|
|
||||||
next: () => {
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
previous: () => {
|
|
||||||
previous();
|
|
||||||
},
|
|
||||||
// The music player exposes no volume API — keep these as no-ops.
|
|
||||||
setVolume: () => {},
|
|
||||||
toggleMute: () => {},
|
|
||||||
}),
|
|
||||||
[togglePlayPause, pause, resume, stop, seek, next, previous],
|
|
||||||
);
|
|
||||||
|
|
||||||
useRegisterPlaybackController(playbackController, isMusicActive);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useRemoteControl } from "@/hooks/useRemoteControl";
|
|
||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
|
|
||||||
@@ -55,8 +54,6 @@ 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);
|
||||||
// Route Jellyfin remote-control messages to the active player.
|
|
||||||
useRemoteControl(lastMessage);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
@@ -222,14 +219,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
IconUrl:
|
IconUrl:
|
||||||
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||||
PlayableMediaTypes: ["Audio", "Video"],
|
PlayableMediaTypes: ["Audio", "Video"],
|
||||||
SupportedCommands: [
|
SupportedCommands: ["Play"],
|
||||||
"Play",
|
|
||||||
"DisplayMessage",
|
|
||||||
"SetVolume",
|
|
||||||
"ToggleMute",
|
|
||||||
"Mute",
|
|
||||||
"Unmute",
|
|
||||||
],
|
|
||||||
SupportsMediaControl: true,
|
SupportsMediaControl: true,
|
||||||
SupportsPersistentIdentifier: true,
|
SupportsPersistentIdentifier: true,
|
||||||
},
|
},
|
||||||
|
|||||||
119
scripts/check-pr-template.mjs
Normal file
119
scripts/check-pr-template.mjs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Validates that a pull request body follows .github/pull_request_template.md:
|
||||||
|
* required sections are filled in and the key checklist items are ticked.
|
||||||
|
*
|
||||||
|
* Usage: bun scripts/check-pr-template.mjs <path-to-pr-body.txt>
|
||||||
|
* Output: a JSON array of human-readable problems (empty array = all good).
|
||||||
|
* Exit: 0 = ok, 1 = one or more problems, 2 = no body file given.
|
||||||
|
*
|
||||||
|
* Env: AUTHOR_ASSOCIATION — when OWNER/MEMBER/COLLABORATOR, the AI-disclosure
|
||||||
|
* check is skipped (maintainers self-police).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const bodyFile = process.argv[2];
|
||||||
|
if (!bodyFile) {
|
||||||
|
console.error("usage: bun scripts/check-pr-template.mjs <pr-body-file>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = readFileSync(bodyFile, "utf8").replace(/\r\n/g, "\n");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`cannot read body file ${bodyFile}: ${e.message}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const association = (process.env.AUTHOR_ASSOCIATION || "").toUpperCase();
|
||||||
|
const isMaintainer = ["OWNER", "MEMBER", "COLLABORATOR"].includes(association);
|
||||||
|
|
||||||
|
// Strip HTML comments in a single linear pass: remove complete `<!-- … -->`
|
||||||
|
// blocks, then drop any leftover unterminated `<!-- …` to end-of-string. This
|
||||||
|
// leaves no `<!--` behind (satisfies CodeQL) without the quadratic re-scan loop
|
||||||
|
// a malicious deeply-nested body could abuse for CPU-DoS.
|
||||||
|
const stripComments = (s) =>
|
||||||
|
s
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
.replace(/<!--[\s\S]*$/, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Grab the text under a heading whose title contains `keyword`, up to the next heading
|
||||||
|
// or the end of the body.
|
||||||
|
const section = (keyword) => {
|
||||||
|
const re = new RegExp(
|
||||||
|
`(?:^|\\n)#{1,4}\\s*[^\\n]*${keyword}[^\\n]*\\n([\\s\\S]*?)(?=\\n#{1,4}\\s|$)`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
const m = body.match(re);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFilled = (content) => {
|
||||||
|
if (content == null) return false;
|
||||||
|
// Template guidance lives in HTML comments; once stripped, a real answer remains.
|
||||||
|
return stripComments(content).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
if (section("Description") === null)
|
||||||
|
issues.push("The **Description** section is missing.");
|
||||||
|
else if (!isFilled(section("Description")))
|
||||||
|
issues.push(
|
||||||
|
"The **Description** section is empty — describe what changed and why.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (section("Ticket") === null)
|
||||||
|
issues.push("The **Ticket / Issue** section is missing.");
|
||||||
|
else if (!isFilled(section("Ticket")))
|
||||||
|
issues.push(
|
||||||
|
"The **Ticket / Issue** section is empty — link an issue or write `N/A`.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (section("Testing Instructions") === null)
|
||||||
|
issues.push("The **Testing Instructions** section is missing.");
|
||||||
|
else if (!isFilled(section("Testing Instructions")))
|
||||||
|
issues.push(
|
||||||
|
"The **Testing Instructions** section is empty — tell reviewers how to test this, or write `N/A`.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const checklist = section("Checklist");
|
||||||
|
if (checklist === null) {
|
||||||
|
issues.push("The **Checklist** section is missing.");
|
||||||
|
} else {
|
||||||
|
if (!/- \[x\][^\n]*contribution guidelines/i.test(checklist))
|
||||||
|
issues.push(
|
||||||
|
"Please read and tick the **contribution guidelines** checklist item.",
|
||||||
|
);
|
||||||
|
if (!isMaintainer && !/- \[x\][^\n]*declared if AI/i.test(checklist))
|
||||||
|
issues.push(
|
||||||
|
"Please tick the **AI disclosure** checklist item (declare whether AI was used).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require the Screenshots section when the PR changes UI (.tsx under app/ or components/).
|
||||||
|
// PR_FILES points to a newline list of changed paths (provided by the workflow).
|
||||||
|
const filesPath = process.env.PR_FILES;
|
||||||
|
if (filesPath && existsSync(filesPath)) {
|
||||||
|
const changed = readFileSync(filesPath, "utf8").split("\n").filter(Boolean);
|
||||||
|
const touchesUI = changed.some(
|
||||||
|
(f) =>
|
||||||
|
/^(app|components)\/.*\.tsx$/.test(f) && !/\.(test|spec)\.tsx$/.test(f),
|
||||||
|
);
|
||||||
|
if (touchesUI) {
|
||||||
|
const shots = section("Screenshots");
|
||||||
|
if (shots === null)
|
||||||
|
issues.push(
|
||||||
|
"This PR changes UI (`.tsx`) — add the **Screenshots / GIFs** section with before/after media.",
|
||||||
|
);
|
||||||
|
else if (!isFilled(shots))
|
||||||
|
issues.push(
|
||||||
|
"This PR changes UI — the **Screenshots / GIFs** section is empty; add screenshots (or write `N/A` if it's genuinely not visual).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(issues));
|
||||||
|
process.exit(issues.length ? 1 : 0);
|
||||||
@@ -27,112 +27,6 @@
|
|||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
"player": {
|
|
||||||
"skip_intro": "Skip Intro",
|
|
||||||
"live": "LIVE",
|
|
||||||
"mpv_player_title": "MPV Player",
|
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
|
||||||
"ends_at": "Ends at {{time}}",
|
|
||||||
"search_subtitles": "Search Subtitles",
|
|
||||||
"subtitle_tracks": "Tracks",
|
|
||||||
"subtitle_search": "Search & Download",
|
|
||||||
"download": "Download",
|
|
||||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
|
||||||
"language": "Language",
|
|
||||||
"results": "Results",
|
|
||||||
"searching": "Searching...",
|
|
||||||
"search_failed": "Search failed",
|
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
|
||||||
"no_subtitles_found": "No subtitles found",
|
|
||||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
|
||||||
"settings": "Settings",
|
|
||||||
"skip_credits": "Skip Credits",
|
|
||||||
"stopPlayback": "Stop Playback",
|
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
|
||||||
"downloaded": "Downloaded",
|
|
||||||
"skip_outro": "Skip Outro",
|
|
||||||
"skip_recap": "Skip Recap",
|
|
||||||
"skip_commercial": "Skip Commercial",
|
|
||||||
"skip_preview": "Skip Preview",
|
|
||||||
"error": "Error",
|
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
|
||||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
|
||||||
"client_error": "Client Error",
|
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
|
||||||
"next_episode": "Next Episode",
|
|
||||||
"refresh_tracks": "Refresh Tracks",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Playback State:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Continue Watching",
|
|
||||||
"go_back": "Go Back",
|
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
|
||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
|
||||||
"downloaded_file_yes": "Yes",
|
|
||||||
"downloaded_file_no": "No",
|
|
||||||
"downloaded_file_cancel": "Cancel",
|
|
||||||
"up_next": "Up next",
|
|
||||||
"next_episode_in": "Next episode in {{seconds}}s",
|
|
||||||
"play_now": "Play now",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"casting_player": {
|
|
||||||
"buffering": "Buffering...",
|
|
||||||
"changing_audio": "Changing audio...",
|
|
||||||
"changing_subtitles": "Changing subtitles...",
|
|
||||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
|
||||||
"connecting": "Connecting to Chromecast...",
|
|
||||||
"unknown_device": "Unknown Device",
|
|
||||||
"ending_at": "Ending at {{time}}",
|
|
||||||
"unknown": "Unknown",
|
|
||||||
"connected": "Connected",
|
|
||||||
"volume": "Volume",
|
|
||||||
"muted": "Muted",
|
|
||||||
"disconnect": "Disconnect",
|
|
||||||
"stop_casting": "Stop Casting",
|
|
||||||
"disconnecting": "Disconnecting...",
|
|
||||||
"chromecast": "Chromecast",
|
|
||||||
"device_name": "Device Name",
|
|
||||||
"playback_settings": "Playback Settings",
|
|
||||||
"version": "Version",
|
|
||||||
"stop": "Stop",
|
|
||||||
"quality": "Quality",
|
|
||||||
"audio": "Audio",
|
|
||||||
"subtitles": "Subtitles",
|
|
||||||
"none": "None",
|
|
||||||
"playback_speed": "Playback Speed",
|
|
||||||
"normal": "Normal",
|
|
||||||
"episodes": "Episodes",
|
|
||||||
"season": "Season {{number}}",
|
|
||||||
"minutes_short": "min",
|
|
||||||
"episode_label": "Episode {{number}}",
|
|
||||||
"forced": "Forced",
|
|
||||||
"device": "Device",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"connection_quality": {
|
|
||||||
"excellent": "Excellent",
|
|
||||||
"good": "Good",
|
|
||||||
"fair": "Fair",
|
|
||||||
"poor": "Poor",
|
|
||||||
"disconnected": "Disconnected"
|
|
||||||
},
|
|
||||||
"error_title": "Chromecast Error",
|
|
||||||
"error_description": "Something went wrong with the cast session",
|
|
||||||
"retry": "Try Again",
|
|
||||||
"critical_error_title": "Multiple Errors Detected",
|
|
||||||
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
|
|
||||||
"track_changed": "Track changed successfully",
|
|
||||||
"audio_track_changed": "Audio track changed",
|
|
||||||
"subtitle_track_changed": "Subtitle track changed",
|
|
||||||
"seeking": "Seeking...",
|
|
||||||
"seeking_error": "Failed to seek",
|
|
||||||
"load_failed": "Failed to load media",
|
|
||||||
"load_retry": "Retrying media load..."
|
|
||||||
},
|
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
@@ -471,23 +365,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",
|
|
||||||
"autoplay_countdown_seconds": "Player countdown (seconds)",
|
|
||||||
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
|
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -804,6 +681,50 @@
|
|||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No Links"
|
"no_links": "No Links"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
|
"error": "Error",
|
||||||
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
|
"client_error": "Client Error",
|
||||||
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
"next_episode": "Next Episode",
|
||||||
|
"refresh_tracks": "Refresh Tracks",
|
||||||
|
"audio_tracks": "Audio Tracks:",
|
||||||
|
"playback_state": "Playback State:",
|
||||||
|
"index": "Index:",
|
||||||
|
"continue_watching": "Continue Watching",
|
||||||
|
"go_back": "Go Back",
|
||||||
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
|
"downloaded_file_yes": "Yes",
|
||||||
|
"downloaded_file_no": "No",
|
||||||
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
"chapter_number": "Chapter {{number}}",
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* Countdown state for Chromecast next-episode autoplay. The watcher
|
|
||||||
* (`useCastAutoplay`) writes it; the casting-player overlay reads it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export interface CastAutoplayState {
|
|
||||||
/** The episode queued to play next. */
|
|
||||||
nextEpisode: BaseItemDto;
|
|
||||||
/** Seconds left before it loads. */
|
|
||||||
secondsRemaining: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Active cast autoplay countdown, or null when none is running. */
|
|
||||||
export const castAutoplayAtom = atom<CastAutoplayState | null>(null);
|
|
||||||
@@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo } from "react";
|
|||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
|
||||||
import { writeInfoLog } from "@/utils/log";
|
import { writeInfoLog } from "@/utils/log";
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
|
|
||||||
@@ -176,9 +175,6 @@ export enum VideoPlayer {
|
|||||||
MPV = 0,
|
MPV = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// TV Typography scale presets
|
// TV Typography scale presets
|
||||||
export enum TVTypographyScale {
|
export enum TVTypographyScale {
|
||||||
Small = "small",
|
Small = "small",
|
||||||
@@ -246,23 +242,10 @@ export type Settings = {
|
|||||||
jellyseerrServerUrl?: string;
|
jellyseerrServerUrl?: string;
|
||||||
useKefinTweaks: boolean;
|
useKefinTweaks: boolean;
|
||||||
hiddenLibraries?: string[];
|
hiddenLibraries?: string[];
|
||||||
/** Chromecast profile selection mode. "auto" detects per device. */
|
enableH265ForChromecast: boolean;
|
||||||
chromecastProfile: ChromecastProfileMode;
|
|
||||||
/** Optional manual Chromecast video bitrate cap, in bits per second. */
|
|
||||||
chromecastMaxBitrate?: number;
|
|
||||||
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;
|
|
||||||
/** Native player next-episode countdown, in seconds. */
|
|
||||||
autoplayCountdownSeconds: number;
|
|
||||||
/** Chromecast next-episode countdown, in seconds. */
|
|
||||||
castAutoplayCountdownSeconds: number;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -362,19 +345,10 @@ export const defaultValues: Settings = {
|
|||||||
jellyseerrServerUrl: undefined,
|
jellyseerrServerUrl: undefined,
|
||||||
useKefinTweaks: false,
|
useKefinTweaks: false,
|
||||||
hiddenLibraries: [],
|
hiddenLibraries: [],
|
||||||
chromecastProfile: "auto",
|
enableH265ForChromecast: false,
|
||||||
chromecastMaxBitrate: undefined,
|
|
||||||
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",
|
|
||||||
autoplayCountdownSeconds: 15,
|
|
||||||
castAutoplayCountdownSeconds: 30,
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { buildChromecastProfile } from "./buildProfile";
|
|
||||||
import { CONSERVATIVE_CAPABILITIES } from "./capabilities";
|
|
||||||
|
|
||||||
describe("buildChromecastProfile", () => {
|
|
||||||
test("conservative caps produce an H.264-only video codec list", () => {
|
|
||||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
|
||||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
|
||||||
(c) => c.Type === "Video",
|
|
||||||
);
|
|
||||||
expect(videoCodecProfile?.Codec).toBe("h264");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("HEVC-capable caps include hevc in the video codec list", () => {
|
|
||||||
const profile = buildChromecastProfile({
|
|
||||||
...CONSERVATIVE_CAPABILITIES,
|
|
||||||
hevc: true,
|
|
||||||
});
|
|
||||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
|
||||||
(c) => c.Type === "Video",
|
|
||||||
);
|
|
||||||
expect(videoCodecProfile?.Codec).toContain("hevc");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxVideoBitrate drives MaxStreamingBitrate", () => {
|
|
||||||
const profile = buildChromecastProfile({
|
|
||||||
...CONSERVATIVE_CAPABILITIES,
|
|
||||||
maxVideoBitrate: 5_000_000,
|
|
||||||
});
|
|
||||||
expect(profile.MaxStreamingBitrate).toBe(5_000_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxAudioChannels constrains transcoding profiles", () => {
|
|
||||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
|
||||||
const videoTranscode = profile.TranscodingProfiles?.find(
|
|
||||||
(p) => p.Type === "Video",
|
|
||||||
);
|
|
||||||
expect(videoTranscode?.MaxAudioChannels).toBe("2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-10bit HEVC caps add a video bit-depth condition", () => {
|
|
||||||
const profile = buildChromecastProfile({
|
|
||||||
...CONSERVATIVE_CAPABILITIES,
|
|
||||||
hevc: true,
|
|
||||||
hevc10bit: false,
|
|
||||||
});
|
|
||||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
|
||||||
(c) => c.Type === "Video",
|
|
||||||
);
|
|
||||||
const bitDepthCondition = videoCodecProfile?.Conditions?.find(
|
|
||||||
(cond) => cond.Property === "VideoBitDepth",
|
|
||||||
);
|
|
||||||
expect(bitDepthCondition).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import type {
|
|
||||||
DeviceProfile,
|
|
||||||
ProfileCondition,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import type { ChromecastCapabilities } from "./capabilities";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a Jellyfin `DeviceProfile` for a Chromecast from its detected capabilities.
|
|
||||||
* Replaces the former static `chromecast.ts` / `chromecasth265.ts` profiles.
|
|
||||||
*/
|
|
||||||
export const buildChromecastProfile = (
|
|
||||||
caps: ChromecastCapabilities,
|
|
||||||
): DeviceProfile => {
|
|
||||||
const videoCodecs = caps.hevc ? "hevc,h264" : "h264";
|
|
||||||
const maxHeight = caps.maxResolution === 2160 ? "2160" : "1080";
|
|
||||||
const maxChannels = String(caps.maxAudioChannels);
|
|
||||||
|
|
||||||
const videoConditions: ProfileCondition[] = [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Height",
|
|
||||||
Value: maxHeight,
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// When HEVC is allowed but 10-bit is not, force the server to transcode
|
|
||||||
// 10-bit sources down to 8-bit.
|
|
||||||
if (caps.hevc && !caps.hevc10bit) {
|
|
||||||
videoConditions.push({
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoBitDepth",
|
|
||||||
Value: "8",
|
|
||||||
IsRequired: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Name: "Chromecast Video Profile",
|
|
||||||
MaxStreamingBitrate: caps.maxVideoBitrate,
|
|
||||||
MaxStaticBitrate: caps.maxVideoBitrate,
|
|
||||||
MusicStreamingTranscodingBitrate: 384000,
|
|
||||||
CodecProfiles: [
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Codec: videoCodecs,
|
|
||||||
Conditions: videoConditions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "Audio",
|
|
||||||
Codec: "aac,mp3,flac,opus,vorbis",
|
|
||||||
// Force transcode of multichannel audio the receiver cannot output.
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "AudioChannels",
|
|
||||||
Value: maxChannels,
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ContainerProfiles: [],
|
|
||||||
DirectPlayProfiles: [
|
|
||||||
{
|
|
||||||
Container: caps.hevc ? "mp4,mkv" : "mp4",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: videoCodecs,
|
|
||||||
AudioCodec: "aac,mp3,opus,vorbis",
|
|
||||||
},
|
|
||||||
{ Container: "mp3", Type: "Audio" },
|
|
||||||
{ Container: "aac", Type: "Audio" },
|
|
||||||
{ Container: "flac", Type: "Audio" },
|
|
||||||
{ Container: "wav", Type: "Audio" },
|
|
||||||
],
|
|
||||||
TranscodingProfiles: [
|
|
||||||
{
|
|
||||||
Container: "ts",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: videoCodecs,
|
|
||||||
AudioCodec: "aac,mp3",
|
|
||||||
Protocol: "hls",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: maxChannels,
|
|
||||||
MinSegments: 2,
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "mp3",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: maxChannels,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: maxChannels,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SubtitleProfiles: [{ Format: "vtt", Method: "Encode" }],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { CONSERVATIVE_CAPABILITIES, detectCapabilities } from "./capabilities";
|
|
||||||
|
|
||||||
describe("detectCapabilities", () => {
|
|
||||||
test("unknown device falls back to the conservative baseline", () => {
|
|
||||||
const caps = detectCapabilities(
|
|
||||||
{ modelName: "Some Unknown TV" },
|
|
||||||
{ profileMode: "auto" },
|
|
||||||
);
|
|
||||||
expect(caps).toEqual(CONSERVATIVE_CAPABILITIES);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("null device falls back to the conservative baseline", () => {
|
|
||||||
const caps = detectCapabilities(null, { profileMode: "auto" });
|
|
||||||
expect(caps).toEqual(CONSERVATIVE_CAPABILITIES);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plain "Chromecast" (gen 1/2/3) gets the conservative baseline', () => {
|
|
||||||
const caps = detectCapabilities(
|
|
||||||
{ modelName: "Chromecast" },
|
|
||||||
{ profileMode: "auto" },
|
|
||||||
);
|
|
||||||
expect(caps.hevc).toBe(false);
|
|
||||||
expect(caps.maxResolution).toBe(1080);
|
|
||||||
expect(caps.maxAudioChannels).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Chromecast Ultra is recognised with HEVC + 4K", () => {
|
|
||||||
const caps = detectCapabilities(
|
|
||||||
{ modelName: "Chromecast Ultra" },
|
|
||||||
{ profileMode: "auto" },
|
|
||||||
);
|
|
||||||
expect(caps.hevc).toBe(true);
|
|
||||||
expect(caps.maxResolution).toBe(2160);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"force-h264" override disables HEVC even on a capable device', () => {
|
|
||||||
const caps = detectCapabilities(
|
|
||||||
{ modelName: "Chromecast Ultra" },
|
|
||||||
{ profileMode: "force-h264" },
|
|
||||||
);
|
|
||||||
expect(caps.hevc).toBe(false);
|
|
||||||
expect(caps.hevc10bit).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"force-hevc" override enables HEVC on the conservative baseline', () => {
|
|
||||||
const caps = detectCapabilities(
|
|
||||||
{ modelName: "Chromecast" },
|
|
||||||
{ profileMode: "force-hevc" },
|
|
||||||
);
|
|
||||||
expect(caps.hevc).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxBitrate override clamps but never raises the bitrate", () => {
|
|
||||||
const lowered = detectCapabilities(
|
|
||||||
{ modelName: "Chromecast" },
|
|
||||||
{ profileMode: "auto", maxBitrate: 3_000_000 },
|
|
||||||
);
|
|
||||||
expect(lowered.maxVideoBitrate).toBe(3_000_000);
|
|
||||||
|
|
||||||
const raised = detectCapabilities(
|
|
||||||
{ modelName: "Chromecast" },
|
|
||||||
{ profileMode: "auto", maxBitrate: 999_000_000 },
|
|
||||||
);
|
|
||||||
expect(raised.maxVideoBitrate).toBe(
|
|
||||||
CONSERVATIVE_CAPABILITIES.maxVideoBitrate,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast device capability detection.
|
|
||||||
*
|
|
||||||
* The Cast SDK exposes a device's `modelName` but no codec-level capability API.
|
|
||||||
* We map known model names to a capability profile and fall back to a conservative
|
|
||||||
* baseline (H.264 / 1080p / stereo) for anything unrecognised — a baseline that
|
|
||||||
* cannot produce an unplayable stream on any Cast receiver.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Profile selection mode, surfaced as an advanced setting. */
|
|
||||||
export type ChromecastProfileMode = "auto" | "force-hevc" | "force-h264";
|
|
||||||
|
|
||||||
export interface ChromecastCapabilities {
|
|
||||||
/** HEVC 8-bit (Main profile) decode support. */
|
|
||||||
hevc: boolean;
|
|
||||||
/** HEVC 10-bit (Main10) decode support. */
|
|
||||||
hevc10bit: boolean;
|
|
||||||
/** Maximum video resolution height. */
|
|
||||||
maxResolution: 1080 | 2160;
|
|
||||||
/** Maximum video bitrate in bits per second. */
|
|
||||||
maxVideoBitrate: number;
|
|
||||||
/** Maximum audio channels the receiver can output. */
|
|
||||||
maxAudioChannels: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal shape we need from the Cast SDK `Device` — keeps this module import-free. */
|
|
||||||
interface DeviceLike {
|
|
||||||
modelName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Overrides derived from user settings. */
|
|
||||||
export interface CapabilityOverrides {
|
|
||||||
profileMode: ChromecastProfileMode;
|
|
||||||
/** Optional manual cap in bits per second. */
|
|
||||||
maxBitrate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Baseline for a 1st/2nd/3rd-gen Chromecast and any unrecognised device.
|
|
||||||
* `maxVideoBitrate` is an initial estimate — see docs/chromecast-test-matrix.md.
|
|
||||||
*/
|
|
||||||
export const CONSERVATIVE_CAPABILITIES: ChromecastCapabilities = {
|
|
||||||
hevc: false,
|
|
||||||
hevc10bit: false,
|
|
||||||
maxResolution: 1080,
|
|
||||||
maxVideoBitrate: 8_000_000,
|
|
||||||
maxAudioChannels: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Known Cast devices keyed by `Device.modelName`. Unlisted models stay conservative. */
|
|
||||||
const CHROMECAST_REGISTRY: Record<string, ChromecastCapabilities> = {
|
|
||||||
"Chromecast Ultra": {
|
|
||||||
hevc: true,
|
|
||||||
hevc10bit: false,
|
|
||||||
maxResolution: 2160,
|
|
||||||
maxVideoBitrate: 20_000_000,
|
|
||||||
maxAudioChannels: 6,
|
|
||||||
},
|
|
||||||
"Chromecast with Google TV": {
|
|
||||||
hevc: true,
|
|
||||||
hevc10bit: true,
|
|
||||||
maxResolution: 2160,
|
|
||||||
maxVideoBitrate: 20_000_000,
|
|
||||||
maxAudioChannels: 6,
|
|
||||||
},
|
|
||||||
"Google TV Streamer": {
|
|
||||||
hevc: true,
|
|
||||||
hevc10bit: true,
|
|
||||||
maxResolution: 2160,
|
|
||||||
maxVideoBitrate: 25_000_000,
|
|
||||||
maxAudioChannels: 8,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the effective capabilities for a Cast device.
|
|
||||||
* Registry lookup → conservative fallback → user overrides applied last.
|
|
||||||
*/
|
|
||||||
export const detectCapabilities = (
|
|
||||||
device: DeviceLike | null,
|
|
||||||
overrides: CapabilityOverrides,
|
|
||||||
): ChromecastCapabilities => {
|
|
||||||
const base =
|
|
||||||
(device?.modelName && CHROMECAST_REGISTRY[device.modelName]) ||
|
|
||||||
CONSERVATIVE_CAPABILITIES;
|
|
||||||
|
|
||||||
const caps: ChromecastCapabilities = { ...base };
|
|
||||||
|
|
||||||
if (overrides.profileMode === "force-hevc") {
|
|
||||||
caps.hevc = true;
|
|
||||||
} else if (overrides.profileMode === "force-h264") {
|
|
||||||
caps.hevc = false;
|
|
||||||
caps.hevc10bit = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrides.maxBitrate && overrides.maxBitrate > 0) {
|
|
||||||
caps.maxVideoBitrate = Math.min(caps.maxVideoBitrate, overrides.maxBitrate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return caps;
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { isLoadFailedError } from "./castErrors";
|
|
||||||
|
|
||||||
describe("isLoadFailedError", () => {
|
|
||||||
test("recognises a status 2100 error message", () => {
|
|
||||||
const error = new Error(
|
|
||||||
"java.lang.Exception: Media control channel status code 2100",
|
|
||||||
);
|
|
||||||
expect(isLoadFailedError(error)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns false for unrelated errors", () => {
|
|
||||||
expect(isLoadFailedError(new Error("network timeout"))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles non-Error values without throwing", () => {
|
|
||||||
expect(isLoadFailedError("status code 2100")).toBe(true);
|
|
||||||
expect(isLoadFailedError(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cast load error classification. Kept dependency-free so it is unit-testable
|
|
||||||
* without pulling React Native modules into the test runtime.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** True when an error is a Cast "LOAD_FAILED" (status 2100) rejection. */
|
|
||||||
export const isLoadFailedError = (error: unknown): boolean => {
|
|
||||||
if (error == null) return false;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return message.includes("2100");
|
|
||||||
};
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Chromecast media loading.
|
|
||||||
*
|
|
||||||
* Owns the getStreamUrl → buildCastMediaInfo → loadMedia sequence that was
|
|
||||||
* previously duplicated across PlayButton and the casting player. Builds the
|
|
||||||
* device profile from detected capabilities and retries once with a forced
|
|
||||||
* conservative profile when the receiver rejects the initial load (status 2100).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import { buildChromecastProfile } from "@/utils/casting/buildProfile";
|
|
||||||
import {
|
|
||||||
type ChromecastProfileMode,
|
|
||||||
detectCapabilities,
|
|
||||||
} from "@/utils/casting/capabilities";
|
|
||||||
import { isLoadFailedError } from "@/utils/casting/castErrors";
|
|
||||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
|
||||||
import { resolveSelection } from "@/utils/casting/selection";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
|
|
||||||
export interface CastLoadOptions {
|
|
||||||
audioStreamIndex?: number;
|
|
||||||
subtitleStreamIndex?: number;
|
|
||||||
maxBitrate?: number;
|
|
||||||
mediaSourceId?: string;
|
|
||||||
startPositionMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CastLoadParams {
|
|
||||||
client: RemoteMediaClient;
|
|
||||||
/** Cast device — only `modelName` is read, for capability detection. */
|
|
||||||
device: { modelName?: string } | null;
|
|
||||||
api: Api;
|
|
||||||
item: BaseItemDto;
|
|
||||||
userId: string;
|
|
||||||
profileMode: ChromecastProfileMode;
|
|
||||||
/** Manual bitrate cap from settings, in bits per second. */
|
|
||||||
maxBitrateSetting?: number;
|
|
||||||
options?: CastLoadOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CastLoadResult = { ok: true } | { ok: false; error: unknown };
|
|
||||||
|
|
||||||
const attemptLoad = async (
|
|
||||||
params: CastLoadParams,
|
|
||||||
caps: Parameters<typeof buildChromecastProfile>[0],
|
|
||||||
): Promise<void> => {
|
|
||||||
const { api, item, userId, client, options } = params;
|
|
||||||
const profile = buildChromecastProfile(caps);
|
|
||||||
|
|
||||||
const selection = resolveSelection(item, {
|
|
||||||
mediaSourceId: options?.mediaSourceId,
|
|
||||||
audioStreamIndex: options?.audioStreamIndex,
|
|
||||||
subtitleStreamIndex: options?.subtitleStreamIndex,
|
|
||||||
maxBitrate: options?.maxBitrate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const startPositionMs = options?.startPositionMs ?? 0;
|
|
||||||
|
|
||||||
const data = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
userId,
|
|
||||||
startTimeTicks: Math.floor(startPositionMs * 10000),
|
|
||||||
deviceProfile: profile,
|
|
||||||
audioStreamIndex: selection.audioStreamIndex,
|
|
||||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
|
||||||
maxStreamingBitrate: selection.maxBitrate,
|
|
||||||
mediaSourceId: selection.mediaSourceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
|
||||||
throw new Error("getStreamUrl returned no URL");
|
|
||||||
}
|
|
||||||
|
|
||||||
const playMethod: "Transcode" | "DirectPlay" = data.mediaSource
|
|
||||||
?.TranscodingUrl
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectPlay";
|
|
||||||
|
|
||||||
await client.loadMedia({
|
|
||||||
mediaInfo: buildCastMediaInfo({
|
|
||||||
item,
|
|
||||||
streamUrl: data.url,
|
|
||||||
api,
|
|
||||||
playSessionId: data.sessionId ?? undefined,
|
|
||||||
selection,
|
|
||||||
playMethod,
|
|
||||||
}),
|
|
||||||
startTime: startPositionMs / 1000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load media onto the connected Chromecast.
|
|
||||||
* On a status-2100 rejection, retries once with a forced conservative profile.
|
|
||||||
*/
|
|
||||||
export const loadCastMedia = async (
|
|
||||||
params: CastLoadParams,
|
|
||||||
): Promise<CastLoadResult> => {
|
|
||||||
const caps = detectCapabilities(params.device, {
|
|
||||||
profileMode: params.profileMode,
|
|
||||||
maxBitrate: params.maxBitrateSetting,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await attemptLoad(params, caps);
|
|
||||||
return { ok: true };
|
|
||||||
} catch (error) {
|
|
||||||
if (!isLoadFailedError(error)) {
|
|
||||||
return { ok: false, error };
|
|
||||||
}
|
|
||||||
// Downgrade-on-failure: one retry with the safest possible profile.
|
|
||||||
// The bitrate cap must also be applied to the explicit getStreamUrl
|
|
||||||
// `maxBitrate` request param — Jellyfin uses that as the effective
|
|
||||||
// ceiling, so the conservative profile alone would not lower it.
|
|
||||||
try {
|
|
||||||
const fallback = detectCapabilities(params.device, {
|
|
||||||
profileMode: "force-h264",
|
|
||||||
});
|
|
||||||
const FALLBACK_MAX_BITRATE = 4_000_000;
|
|
||||||
const fallbackParams: CastLoadParams = {
|
|
||||||
...params,
|
|
||||||
options: {
|
|
||||||
...params.options,
|
|
||||||
maxBitrate: Math.min(
|
|
||||||
params.options?.maxBitrate ?? Number.POSITIVE_INFINITY,
|
|
||||||
FALLBACK_MAX_BITRATE,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await attemptLoad(fallbackParams, {
|
|
||||||
...fallback,
|
|
||||||
maxVideoBitrate: FALLBACK_MAX_BITRATE,
|
|
||||||
maxAudioChannels: 2,
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
} catch (retryError) {
|
|
||||||
return { ok: false, error: retryError };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { findNextEpisode } from "./episodes";
|
|
||||||
|
|
||||||
const ep = (id: string) => ({ Id: id });
|
|
||||||
|
|
||||||
describe("findNextEpisode", () => {
|
|
||||||
test("returns the episode after the current one", () => {
|
|
||||||
expect(findNextEpisode([ep("a"), ep("b"), ep("c")], "b")).toEqual(ep("c"));
|
|
||||||
});
|
|
||||||
test("returns null on the last episode", () => {
|
|
||||||
expect(findNextEpisode([ep("a"), ep("b")], "b")).toBeNull();
|
|
||||||
});
|
|
||||||
test("returns null when the current id is not found", () => {
|
|
||||||
expect(findNextEpisode([ep("a"), ep("b")], "x")).toBeNull();
|
|
||||||
});
|
|
||||||
test("returns null for an empty list", () => {
|
|
||||||
expect(findNextEpisode([], "a")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* Episode-list helpers for the casting player and the autoplay watcher.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
UserDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
|
|
||||||
/** The episode following `currentId` in `episodes`, or null if none / not found. */
|
|
||||||
export const findNextEpisode = (
|
|
||||||
episodes: BaseItemDto[],
|
|
||||||
currentId: string | null | undefined,
|
|
||||||
): BaseItemDto | null => {
|
|
||||||
const index = episodes.findIndex((e) => e.Id === currentId);
|
|
||||||
if (index < 0 || index + 1 >= episodes.length) return null;
|
|
||||||
return episodes[index + 1];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch every episode of the series that owns the current episode.
|
|
||||||
* Mirrors the call previously inlined in `useCastEpisodes`: no season filter,
|
|
||||||
* and the same `userId` quirk (undefined when an access token is present, else
|
|
||||||
* the empty string) so the request payload stays byte-identical.
|
|
||||||
*/
|
|
||||||
export const fetchSeriesEpisodes = async (
|
|
||||||
api: Api,
|
|
||||||
_user: UserDto,
|
|
||||||
seriesId: string,
|
|
||||||
): Promise<BaseItemDto[]> => {
|
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
|
||||||
seriesId,
|
|
||||||
userId: api.accessToken ? undefined : "",
|
|
||||||
});
|
|
||||||
// Drop "Virtual" (missing) episodes — e.g. an empty Specials/Season 0 entry
|
|
||||||
// that has no media file. They must not appear in the cast episode list nor
|
|
||||||
// be offered as prev/next/autoplay targets (they can't be cast).
|
|
||||||
return (res.data.Items ?? []).filter((e) => e.LocationType !== "Virtual");
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Helper Functions
|
|
||||||
* Common utilities for casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format milliseconds to HH:MM:SS or MM:SS
|
|
||||||
*/
|
|
||||||
export const formatTime = (ms: number): string => {
|
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate ending time based on current progress and duration.
|
|
||||||
* Uses locale-aware formatting when available.
|
|
||||||
*/
|
|
||||||
export const calculateEndingTime = (
|
|
||||||
currentMs: number,
|
|
||||||
durationMs: number,
|
|
||||||
): string => {
|
|
||||||
const remainingMs = durationMs - currentMs;
|
|
||||||
const endTime = new Date(Date.now() + remainingMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return endTime.toLocaleTimeString(undefined, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Fallback for environments without Intl support
|
|
||||||
const hours = endTime.getHours();
|
|
||||||
const minutes = endTime.getMinutes();
|
|
||||||
const ampm = hours >= 12 ? "PM" : "AM";
|
|
||||||
const displayHours = hours % 12 || 12;
|
|
||||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get poster URL for item with specified dimensions
|
|
||||||
*/
|
|
||||||
export const getPosterUrl = (
|
|
||||||
baseUrl: string | undefined,
|
|
||||||
itemId: string | undefined,
|
|
||||||
tag: string | undefined,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
): string | null => {
|
|
||||||
if (!baseUrl || !itemId) return null;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
maxWidth: width.toString(),
|
|
||||||
maxHeight: height.toString(),
|
|
||||||
quality: "90",
|
|
||||||
...(tag && { tag }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate title to max length with ellipsis
|
|
||||||
*/
|
|
||||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
|
||||||
if (maxLength < 4) return title.substring(0, maxLength);
|
|
||||||
if (title.length <= maxLength) return title;
|
|
||||||
return `${title.substring(0, maxLength - 3)}...`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if current time is within a segment
|
|
||||||
*/
|
|
||||||
export const isWithinSegment = (
|
|
||||||
currentMs: number,
|
|
||||||
segment: { start: number; end: number } | null,
|
|
||||||
): boolean => {
|
|
||||||
if (!segment) return false;
|
|
||||||
const currentSeconds = currentMs / 1000;
|
|
||||||
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format trickplay time from {hours, minutes, seconds} to display string.
|
|
||||||
* Produces "H:MM:SS" when hours > 0, otherwise "MM:SS".
|
|
||||||
*/
|
|
||||||
export const formatTrickplayTime = (time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
}): string => {
|
|
||||||
const mm = String(time.minutes).padStart(2, "0");
|
|
||||||
const ss = String(time.seconds).padStart(2, "0");
|
|
||||||
return time.hours > 0 ? `${time.hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
||||||
};
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared helper to build Chromecast media metadata.
|
|
||||||
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { MediaStreamType } from "react-native-google-cast";
|
|
||||||
import type { CastSelection } from "@/utils/casting/types";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
|
|
||||||
*
|
|
||||||
* NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH
|
|
||||||
* from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml"
|
|
||||||
* actually BREAKS playback on many receivers. Always use "video/mp4" unless
|
|
||||||
* you have a custom receiver that explicitly handles other MIME types.
|
|
||||||
*/
|
|
||||||
export const buildCastMediaInfo = ({
|
|
||||||
item,
|
|
||||||
streamUrl,
|
|
||||||
api,
|
|
||||||
contentType,
|
|
||||||
isLive = false,
|
|
||||||
playSessionId,
|
|
||||||
selection,
|
|
||||||
playMethod,
|
|
||||||
}: {
|
|
||||||
item: BaseItemDto;
|
|
||||||
streamUrl: string;
|
|
||||||
api: Api;
|
|
||||||
/** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */
|
|
||||||
contentType?: string;
|
|
||||||
/** Set true for live TV streams to use MediaStreamType.LIVE. */
|
|
||||||
isLive?: boolean;
|
|
||||||
/** Jellyfin PlaySessionId, embedded in customData for progress reporting. */
|
|
||||||
playSessionId?: string;
|
|
||||||
/** Active track / quality / version selection, embedded in customData. */
|
|
||||||
selection?: CastSelection;
|
|
||||||
/** "Transcode" when the stream is a server transcode, else "DirectPlay". */
|
|
||||||
playMethod?: "Transcode" | "DirectPlay";
|
|
||||||
}) => {
|
|
||||||
if (!item.Id) {
|
|
||||||
throw new Error("Missing item.Id for media load — cannot build contentId");
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemId: string = item.Id;
|
|
||||||
const streamDuration = item.RunTimeTicks
|
|
||||||
? item.RunTimeTicks / 10000000
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const buildImages = (urls: (string | null | undefined)[]) =>
|
|
||||||
urls.filter(Boolean).map((url) => ({ url: url as string }));
|
|
||||||
|
|
||||||
const buildItemMetadata = () => {
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
return {
|
|
||||||
type: "tvShow" as const,
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: buildImages([
|
|
||||||
getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Movie") {
|
|
||||||
return {
|
|
||||||
type: "movie" as const,
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: buildImages([
|
|
||||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "generic" as const,
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: buildImages([
|
|
||||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const metadata = buildItemMetadata();
|
|
||||||
|
|
||||||
// Build a slim customData payload with only the fields the casting-player needs.
|
|
||||||
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
|
|
||||||
// especially for movies with many chapters, media sources, and people.
|
|
||||||
const slimCustomData: Partial<BaseItemDto> & {
|
|
||||||
playSessionId?: string;
|
|
||||||
selection?: CastSelection;
|
|
||||||
playMethod?: "Transcode" | "DirectPlay";
|
|
||||||
} = {
|
|
||||||
playSessionId,
|
|
||||||
selection,
|
|
||||||
playMethod,
|
|
||||||
Id: item.Id,
|
|
||||||
Name: item.Name,
|
|
||||||
Type: item.Type,
|
|
||||||
SeriesName: item.SeriesName,
|
|
||||||
SeriesId: item.SeriesId,
|
|
||||||
SeasonId: item.SeasonId,
|
|
||||||
IndexNumber: item.IndexNumber,
|
|
||||||
ParentIndexNumber: item.ParentIndexNumber,
|
|
||||||
ImageTags: item.ImageTags,
|
|
||||||
RunTimeTicks: item.RunTimeTicks,
|
|
||||||
Overview: item.Overview,
|
|
||||||
MediaStreams: item.MediaStreams,
|
|
||||||
MediaSources: item.MediaSources?.map((src) => ({
|
|
||||||
Id: src.Id,
|
|
||||||
Bitrate: src.Bitrate,
|
|
||||||
Container: src.Container,
|
|
||||||
Name: src.Name,
|
|
||||||
DefaultAudioStreamIndex: src.DefaultAudioStreamIndex,
|
|
||||||
DefaultSubtitleStreamIndex: src.DefaultSubtitleStreamIndex,
|
|
||||||
})),
|
|
||||||
UserData: item.UserData
|
|
||||||
? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks }
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentId: itemId,
|
|
||||||
contentUrl: streamUrl,
|
|
||||||
contentType: contentType || "video/mp4",
|
|
||||||
streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED,
|
|
||||||
streamDuration,
|
|
||||||
customData: slimCustomData,
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
resolveDefaultAudioIndex,
|
|
||||||
resolveSelection,
|
|
||||||
selectionsEqual,
|
|
||||||
} from "./selection";
|
|
||||||
|
|
||||||
const item: BaseItemDto = {
|
|
||||||
Id: "item-1",
|
|
||||||
MediaSources: [
|
|
||||||
{
|
|
||||||
Id: "src-a",
|
|
||||||
DefaultAudioStreamIndex: 2,
|
|
||||||
DefaultSubtitleStreamIndex: 3,
|
|
||||||
MediaStreams: [
|
|
||||||
{ Type: "Video", Index: 0 },
|
|
||||||
{ Type: "Audio", Index: 1, IsDefault: false },
|
|
||||||
{ Type: "Audio", Index: 2, IsDefault: true },
|
|
||||||
{ Type: "Subtitle", Index: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "src-b",
|
|
||||||
MediaStreams: [
|
|
||||||
{ Type: "Video", Index: 0 },
|
|
||||||
{ Type: "Audio", Index: 1, IsDefault: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("resolveDefaultAudioIndex", () => {
|
|
||||||
test("uses the source's DefaultAudioStreamIndex when present", () => {
|
|
||||||
expect(resolveDefaultAudioIndex(item, "src-a")).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to the first audio stream when no default flag", () => {
|
|
||||||
expect(resolveDefaultAudioIndex(item, "src-b")).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveSelection", () => {
|
|
||||||
test("fills every field from server defaults of the first source", () => {
|
|
||||||
const sel = resolveSelection(item, {});
|
|
||||||
expect(sel.mediaSourceId).toBe("src-a");
|
|
||||||
expect(sel.audioStreamIndex).toBe(2);
|
|
||||||
expect(sel.subtitleStreamIndex).toBe(3);
|
|
||||||
expect(sel.maxBitrate).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a partial overrides defaults and keeps the rest", () => {
|
|
||||||
const sel = resolveSelection(item, {
|
|
||||||
audioStreamIndex: 1,
|
|
||||||
maxBitrate: 4_000_000,
|
|
||||||
});
|
|
||||||
expect(sel.audioStreamIndex).toBe(1);
|
|
||||||
expect(sel.maxBitrate).toBe(4_000_000);
|
|
||||||
expect(sel.subtitleStreamIndex).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching version resolves that version's defaults", () => {
|
|
||||||
const sel = resolveSelection(item, { mediaSourceId: "src-b" });
|
|
||||||
expect(sel.mediaSourceId).toBe("src-b");
|
|
||||||
expect(sel.audioStreamIndex).toBe(1);
|
|
||||||
expect(sel.subtitleStreamIndex).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("selectionsEqual", () => {
|
|
||||||
test("true for identical selections", () => {
|
|
||||||
const a = {
|
|
||||||
mediaSourceId: "s",
|
|
||||||
audioStreamIndex: 1,
|
|
||||||
subtitleStreamIndex: -1,
|
|
||||||
};
|
|
||||||
expect(selectionsEqual(a, { ...a })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("false when any field differs", () => {
|
|
||||||
const a = {
|
|
||||||
mediaSourceId: "s",
|
|
||||||
audioStreamIndex: 1,
|
|
||||||
subtitleStreamIndex: -1,
|
|
||||||
};
|
|
||||||
expect(selectionsEqual(a, { ...a, audioStreamIndex: 2 })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cast selection resolution — pure helpers, no React Native imports, so they
|
|
||||||
* are unit-testable under `bun test`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { CastSelection } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the default audio stream index for an item / media source.
|
|
||||||
* Prefers the source's `DefaultAudioStreamIndex`, then the first audio stream.
|
|
||||||
*/
|
|
||||||
export const resolveDefaultAudioIndex = (
|
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSourceId?: string,
|
|
||||||
): number | undefined => {
|
|
||||||
const source = mediaSourceId
|
|
||||||
? item.MediaSources?.find((s) => s.Id === mediaSourceId)
|
|
||||||
: item.MediaSources?.[0];
|
|
||||||
if (source?.DefaultAudioStreamIndex != null) {
|
|
||||||
return source.DefaultAudioStreamIndex;
|
|
||||||
}
|
|
||||||
const streams = source?.MediaStreams ?? item.MediaStreams;
|
|
||||||
const audio =
|
|
||||||
streams?.find((s) => s.Type === "Audio" && s.IsDefault) ??
|
|
||||||
streams?.find((s) => s.Type === "Audio");
|
|
||||||
return audio?.Index ?? undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete a partial selection with the item's server defaults.
|
|
||||||
* Used on first load, on episode change, and when switching version.
|
|
||||||
*/
|
|
||||||
export const resolveSelection = (
|
|
||||||
item: BaseItemDto,
|
|
||||||
partial: Partial<CastSelection>,
|
|
||||||
): CastSelection => {
|
|
||||||
const mediaSourceId =
|
|
||||||
partial.mediaSourceId ?? item.MediaSources?.[0]?.Id ?? "";
|
|
||||||
const source = item.MediaSources?.find((s) => s.Id === mediaSourceId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSourceId,
|
|
||||||
audioStreamIndex:
|
|
||||||
partial.audioStreamIndex ??
|
|
||||||
resolveDefaultAudioIndex(item, mediaSourceId) ??
|
|
||||||
-1,
|
|
||||||
subtitleStreamIndex:
|
|
||||||
partial.subtitleStreamIndex ?? source?.DefaultSubtitleStreamIndex ?? -1,
|
|
||||||
maxBitrate: partial.maxBitrate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** True when two selections are equivalent — used to reconcile optimistic state. */
|
|
||||||
export const selectionsEqual = (a: CastSelection, b: CastSelection): boolean =>
|
|
||||||
a.mediaSourceId === b.mediaSourceId &&
|
|
||||||
a.audioStreamIndex === b.audioStreamIndex &&
|
|
||||||
a.subtitleStreamIndex === b.subtitleStreamIndex &&
|
|
||||||
a.maxBitrate === b.maxBitrate;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Types and Options
|
|
||||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
|
||||||
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
export type CastProtocol = "chromecast";
|
|
||||||
|
|
||||||
export interface CastDevice {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
protocol: CastProtocol;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CastPlayerState {
|
|
||||||
isConnected: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
currentDevice: CastDevice | null;
|
|
||||||
protocol: CastProtocol | null;
|
|
||||||
progress: number;
|
|
||||||
duration: number;
|
|
||||||
volume: number;
|
|
||||||
isBuffering: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioTrack {
|
|
||||||
index: number;
|
|
||||||
language: string;
|
|
||||||
codec: string;
|
|
||||||
displayTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
|
||||||
index: number;
|
|
||||||
language: string;
|
|
||||||
codec: string;
|
|
||||||
displayTitle: string;
|
|
||||||
isForced: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaSource {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
bitrate?: number;
|
|
||||||
container: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CASTING_CONSTANTS = {
|
|
||||||
POSTER_WIDTH: 300,
|
|
||||||
POSTER_HEIGHT: 450,
|
|
||||||
ANIMATION_DURATION: 300,
|
|
||||||
CONTROL_HIDE_DELAY: 5000,
|
|
||||||
PROGRESS_UPDATE_INTERVAL: 1000,
|
|
||||||
SEEK_FORWARD_SECONDS: 10,
|
|
||||||
SEEK_BACKWARD_SECONDS: 10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const DEFAULT_CAST_STATE: CastPlayerState = {
|
|
||||||
isConnected: false,
|
|
||||||
isPlaying: false,
|
|
||||||
currentItem: null,
|
|
||||||
currentDevice: null,
|
|
||||||
protocol: null,
|
|
||||||
progress: 0,
|
|
||||||
duration: 0,
|
|
||||||
volume: 0.5,
|
|
||||||
isBuffering: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is currently loaded on the cast — the single source of truth for
|
|
||||||
* audio / subtitle / quality / version selection.
|
|
||||||
*/
|
|
||||||
export interface CastSelection {
|
|
||||||
/** MediaSource (version) id. */
|
|
||||||
mediaSourceId: string;
|
|
||||||
/** Absolute MediaStream index of the audio track. */
|
|
||||||
audioStreamIndex: number;
|
|
||||||
/** Absolute MediaStream index of the subtitle track; -1 = subtitles off. */
|
|
||||||
subtitleStreamIndex: number;
|
|
||||||
/** Quality cap in bits/second; undefined = unconstrained. */
|
|
||||||
maxBitrate?: number;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast player configuration and types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ChromecastSegmentData {
|
|
||||||
intro: { start: number; end: number } | null;
|
|
||||||
credits: { start: number; end: number } | null;
|
|
||||||
recap: { start: number; end: number } | null;
|
|
||||||
commercial: { start: number; end: number }[];
|
|
||||||
preview: { start: number; end: number }[];
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* The canonical playback-control surface. Every player (cast, native video,
|
|
||||||
* music) implements this interface and registers itself as the active
|
|
||||||
* controller while it is playing, so remote-control commands can be routed to
|
|
||||||
* whatever is currently playing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { atom, useSetAtom } from "jotai";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export interface PlaybackController {
|
|
||||||
playPause(): void;
|
|
||||||
pause(): void;
|
|
||||||
unpause(): void;
|
|
||||||
stop(): void;
|
|
||||||
/** Absolute seek position in milliseconds. */
|
|
||||||
seek(positionMs: number): void;
|
|
||||||
next(): void;
|
|
||||||
previous(): void;
|
|
||||||
/** Volume 0-1. */
|
|
||||||
setVolume(level: number): void;
|
|
||||||
toggleMute(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The currently-active playback controller, or null when nothing is playing. */
|
|
||||||
export const activePlaybackControllerAtom = atom<PlaybackController | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register `controller` as the active playback controller while `active` is
|
|
||||||
* true. Cleared on unmount or when `active` becomes false.
|
|
||||||
*
|
|
||||||
* The registered value is a *stable proxy* whose identity never changes — it
|
|
||||||
* forwards each call to whatever `controller` is current (tracked via a ref).
|
|
||||||
* This keeps the registration effect's dependencies stable (`active` only), so
|
|
||||||
* a `controller` that is recreated every render does NOT re-run the effect and
|
|
||||||
* cannot cause a `setState`/render loop.
|
|
||||||
*/
|
|
||||||
export const useRegisterPlaybackController = (
|
|
||||||
controller: PlaybackController | null,
|
|
||||||
active: boolean,
|
|
||||||
): void => {
|
|
||||||
const setController = useSetAtom(activePlaybackControllerAtom);
|
|
||||||
|
|
||||||
// Always points at the latest controller passed in.
|
|
||||||
const controllerRef = useRef(controller);
|
|
||||||
controllerRef.current = controller;
|
|
||||||
|
|
||||||
// Created once; its identity is stable for the component's lifetime.
|
|
||||||
const proxyRef = useRef<PlaybackController | null>(null);
|
|
||||||
if (proxyRef.current === null) {
|
|
||||||
proxyRef.current = {
|
|
||||||
playPause: () => controllerRef.current?.playPause(),
|
|
||||||
pause: () => controllerRef.current?.pause(),
|
|
||||||
unpause: () => controllerRef.current?.unpause(),
|
|
||||||
stop: () => controllerRef.current?.stop(),
|
|
||||||
seek: (positionMs) => controllerRef.current?.seek(positionMs),
|
|
||||||
next: () => controllerRef.current?.next(),
|
|
||||||
previous: () => controllerRef.current?.previous(),
|
|
||||||
setVolume: (level) => controllerRef.current?.setVolume(level),
|
|
||||||
toggleMute: () => controllerRef.current?.toggleMute(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
const proxy = proxyRef.current;
|
|
||||||
setController(proxy);
|
|
||||||
return () => {
|
|
||||||
setController((current) => (current === proxy ? null : current));
|
|
||||||
};
|
|
||||||
}, [active, setController]);
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { mapRemoteCommand } from "./remoteCommands";
|
|
||||||
|
|
||||||
describe("mapRemoteCommand — Playstate", () => {
|
|
||||||
test("maps Pause", () => {
|
|
||||||
expect(
|
|
||||||
mapRemoteCommand({
|
|
||||||
MessageType: "Playstate",
|
|
||||||
Data: { Command: "Pause" },
|
|
||||||
}),
|
|
||||||
).toEqual({ kind: "pause" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps Stop, PlayPause, Unpause, NextTrack, PreviousTrack", () => {
|
|
||||||
const m = (c: string) =>
|
|
||||||
mapRemoteCommand({ MessageType: "Playstate", Data: { Command: c } });
|
|
||||||
expect(m("Stop")).toEqual({ kind: "stop" });
|
|
||||||
expect(m("PlayPause")).toEqual({ kind: "playPause" });
|
|
||||||
expect(m("Unpause")).toEqual({ kind: "unpause" });
|
|
||||||
expect(m("NextTrack")).toEqual({ kind: "next" });
|
|
||||||
expect(m("PreviousTrack")).toEqual({ kind: "previous" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps Seek, converting ticks to milliseconds", () => {
|
|
||||||
expect(
|
|
||||||
mapRemoteCommand({
|
|
||||||
MessageType: "Playstate",
|
|
||||||
Data: { Command: "Seek", SeekPositionTicks: 600_000_000 },
|
|
||||||
}),
|
|
||||||
).toEqual({ kind: "seek", positionMs: 60_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for Seek with no position", () => {
|
|
||||||
expect(
|
|
||||||
mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Seek" } }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for an unknown command", () => {
|
|
||||||
expect(
|
|
||||||
mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Wat" } }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mapRemoteCommand — GeneralCommand", () => {
|
|
||||||
test("maps SetVolume, converting 0-100 to 0-1", () => {
|
|
||||||
expect(
|
|
||||||
mapRemoteCommand({
|
|
||||||
MessageType: "GeneralCommand",
|
|
||||||
Data: { Name: "SetVolume", Arguments: { Volume: "40" } },
|
|
||||||
}),
|
|
||||||
).toEqual({ kind: "setVolume", level: 0.4 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clamps SetVolume to 0-1", () => {
|
|
||||||
const r = mapRemoteCommand({
|
|
||||||
MessageType: "GeneralCommand",
|
|
||||||
Data: { Name: "SetVolume", Arguments: { Volume: "250" } },
|
|
||||||
});
|
|
||||||
expect(r).toEqual({ kind: "setVolume", level: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps ToggleMute / Mute / Unmute to toggleMute", () => {
|
|
||||||
const m = (n: string) =>
|
|
||||||
mapRemoteCommand({ MessageType: "GeneralCommand", Data: { Name: n } });
|
|
||||||
expect(m("ToggleMute")).toEqual({ kind: "toggleMute" });
|
|
||||||
expect(m("Mute")).toEqual({ kind: "toggleMute" });
|
|
||||||
expect(m("Unmute")).toEqual({ kind: "toggleMute" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps DisplayMessage from Arguments.Text", () => {
|
|
||||||
expect(
|
|
||||||
mapRemoteCommand({
|
|
||||||
MessageType: "GeneralCommand",
|
|
||||||
Data: { Name: "DisplayMessage", Arguments: { Text: "Hello" } },
|
|
||||||
}),
|
|
||||||
).toEqual({ kind: "displayMessage", text: "Hello" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mapRemoteCommand — other", () => {
|
|
||||||
test("returns null for unrelated message types", () => {
|
|
||||||
expect(mapRemoteCommand({ MessageType: "KeepAlive", Data: {} })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure mapping from a Jellyfin remote-control WebSocket message to a typed
|
|
||||||
* action. Dependency-free so it is unit-testable under `bun test`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A WebSocket message envelope (subset). */
|
|
||||||
export interface RemoteWsMessage {
|
|
||||||
MessageType: string;
|
|
||||||
Data?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RemoteAction =
|
|
||||||
| { kind: "playPause" }
|
|
||||||
| { kind: "pause" }
|
|
||||||
| { kind: "unpause" }
|
|
||||||
| { kind: "stop" }
|
|
||||||
| { kind: "seek"; positionMs: number }
|
|
||||||
| { kind: "next" }
|
|
||||||
| { kind: "previous" }
|
|
||||||
| { kind: "setVolume"; level: number }
|
|
||||||
| { kind: "toggleMute" }
|
|
||||||
| { kind: "displayMessage"; text: string };
|
|
||||||
|
|
||||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
|
||||||
|
|
||||||
const mapPlaystate = (data: Record<string, unknown>): RemoteAction | null => {
|
|
||||||
switch (data.Command) {
|
|
||||||
case "PlayPause":
|
|
||||||
return { kind: "playPause" };
|
|
||||||
case "Pause":
|
|
||||||
return { kind: "pause" };
|
|
||||||
case "Unpause":
|
|
||||||
return { kind: "unpause" };
|
|
||||||
case "Stop":
|
|
||||||
return { kind: "stop" };
|
|
||||||
case "NextTrack":
|
|
||||||
return { kind: "next" };
|
|
||||||
case "PreviousTrack":
|
|
||||||
return { kind: "previous" };
|
|
||||||
case "Seek": {
|
|
||||||
const ticks = data.SeekPositionTicks;
|
|
||||||
if (typeof ticks !== "number") return null;
|
|
||||||
return { kind: "seek", positionMs: Math.floor(ticks / 10000) };
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapGeneralCommand = (
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
): RemoteAction | null => {
|
|
||||||
const args = (data.Arguments ?? {}) as Record<string, unknown>;
|
|
||||||
switch (data.Name) {
|
|
||||||
case "SetVolume": {
|
|
||||||
const volume = Number(args.Volume);
|
|
||||||
if (!Number.isFinite(volume)) return null;
|
|
||||||
return { kind: "setVolume", level: clamp01(volume / 100) };
|
|
||||||
}
|
|
||||||
case "Mute":
|
|
||||||
case "Unmute":
|
|
||||||
case "ToggleMute":
|
|
||||||
return { kind: "toggleMute" };
|
|
||||||
case "DisplayMessage": {
|
|
||||||
const text = args.Text ?? args.Header;
|
|
||||||
if (!text) return null;
|
|
||||||
return { kind: "displayMessage", text: String(text) };
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Map a remote-control WS message to a typed action, or null if unhandled. */
|
|
||||||
export const mapRemoteCommand = (
|
|
||||||
message: RemoteWsMessage,
|
|
||||||
): RemoteAction | null => {
|
|
||||||
const data = (message.Data ?? {}) as Record<string, unknown>;
|
|
||||||
if (message.MessageType === "Playstate") return mapPlaystate(data);
|
|
||||||
if (message.MessageType === "GeneralCommand") return mapGeneralCommand(data);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -74,16 +74,10 @@ export const getSegmentsForItem = (
|
|||||||
): {
|
): {
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
} => {
|
} => {
|
||||||
return {
|
return {
|
||||||
introSegments: item.introSegments || [],
|
introSegments: item.introSegments || [],
|
||||||
creditSegments: item.creditSegments || [],
|
creditSegments: item.creditSegments || [],
|
||||||
recapSegments: item.recapSegments || [],
|
|
||||||
commercialSegments: item.commercialSegments || [],
|
|
||||||
previewSegments: item.previewSegments || [],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,9 +95,6 @@ const fetchMediaSegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
} | null> => {
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||||
@@ -111,22 +102,13 @@ const fetchMediaSegments = async (
|
|||||||
{
|
{
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
params: {
|
params: {
|
||||||
includeSegmentTypes: [
|
includeSegmentTypes: ["Intro", "Outro"],
|
||||||
"Intro",
|
|
||||||
"Outro",
|
|
||||||
"Recap",
|
|
||||||
"Commercial",
|
|
||||||
"Preview",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const introSegments: MediaTimeSegment[] = [];
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
const creditSegments: MediaTimeSegment[] = [];
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
const recapSegments: MediaTimeSegment[] = [];
|
|
||||||
const commercialSegments: MediaTimeSegment[] = [];
|
|
||||||
const previewSegments: MediaTimeSegment[] = [];
|
|
||||||
|
|
||||||
response.data.Items.forEach((segment) => {
|
response.data.Items.forEach((segment) => {
|
||||||
const timeSegment: MediaTimeSegment = {
|
const timeSegment: MediaTimeSegment = {
|
||||||
@@ -142,27 +124,13 @@ const fetchMediaSegments = async (
|
|||||||
case "Outro":
|
case "Outro":
|
||||||
creditSegments.push(timeSegment);
|
creditSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case "Recap":
|
// Optionally handle other types like Recap, Commercial, Preview
|
||||||
recapSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case "Commercial":
|
|
||||||
commercialSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case "Preview":
|
|
||||||
previewSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { introSegments, creditSegments };
|
||||||
introSegments,
|
|
||||||
creditSegments,
|
|
||||||
recapSegments,
|
|
||||||
commercialSegments,
|
|
||||||
previewSegments,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Return null to indicate we should try legacy endpoints
|
// Return null to indicate we should try legacy endpoints
|
||||||
return null;
|
return null;
|
||||||
@@ -178,13 +146,11 @@ const fetchLegacySegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
}> => {
|
}> => {
|
||||||
const introSegments: MediaTimeSegment[] = [];
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
const creditSegments: MediaTimeSegment[] = [];
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
const [introRes, creditRes] = await Promise.allSettled([
|
const [introRes, creditRes] = await Promise.allSettled([
|
||||||
api.axiosInstance.get<IntroTimestamps>(
|
api.axiosInstance.get<IntroTimestamps>(
|
||||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
@@ -204,21 +170,21 @@ const fetchLegacySegments = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
|
if (
|
||||||
|
creditRes.status === "fulfilled" &&
|
||||||
|
creditRes.value.data.Credits.Valid
|
||||||
|
) {
|
||||||
creditSegments.push({
|
creditSegments.push({
|
||||||
startTime: creditRes.value.data.Credits.Start,
|
startTime: creditRes.value.data.Credits.Start,
|
||||||
endTime: creditRes.value.data.Credits.End,
|
endTime: creditRes.value.data.Credits.End,
|
||||||
text: "Credits",
|
text: "Credits",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch legacy segments", error);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return { introSegments, creditSegments };
|
||||||
introSegments,
|
|
||||||
creditSegments,
|
|
||||||
recapSegments: [],
|
|
||||||
commercialSegments: [],
|
|
||||||
previewSegments: [],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAndParseSegments = async (
|
export const fetchAndParseSegments = async (
|
||||||
@@ -227,9 +193,6 @@ export const fetchAndParseSegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
}> => {
|
}> => {
|
||||||
// Try new API first (Jellyfin 10.11+)
|
// Try new API first (Jellyfin 10.11+)
|
||||||
const newSegments = await fetchMediaSegments(itemId, api);
|
const newSegments = await fetchMediaSegments(itemId, api);
|
||||||
|
|||||||
Reference in New Issue
Block a user