mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-01 07:58:08 +00:00
Compare commits
1 Commits
refactor-c
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb61ae94b |
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)
|
||||||
|
|||||||
@@ -1,233 +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}
|
|
||||||
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}
|
|
||||||
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}
|
|
||||||
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}
|
|
||||||
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}
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -11,7 +11,6 @@ import { withLayoutContext } from "expo-router";
|
|||||||
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 { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
|
||||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -119,7 +118,6 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<CastingMiniPlayer />
|
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
13
bun.lock
13
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.7.2",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -257,7 +258,7 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q=="],
|
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ=="],
|
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="],
|
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="],
|
||||||
|
|
||||||
@@ -1707,7 +1708,7 @@
|
|||||||
|
|
||||||
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
||||||
|
|
||||||
"react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="],
|
"react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||||
|
|
||||||
@@ -2213,6 +2214,8 @@
|
|||||||
|
|
||||||
"@react-native/babel-preset/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
"@react-native/babel-preset/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
||||||
|
|
||||||
|
"@react-native/babel-preset/@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ=="],
|
||||||
|
|
||||||
"@react-native/babel-preset/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
"@react-native/babel-preset/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||||
|
|
||||||
"@react-native/codegen/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
"@react-native/codegen/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
||||||
@@ -2435,7 +2438,9 @@
|
|||||||
|
|
||||||
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
||||||
|
|
||||||
"react-native-worklets/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native-worklets/@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
|
||||||
|
|
||||||
|
"react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
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 } 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, {
|
||||||
@@ -14,7 +10,6 @@ import GoogleCast, {
|
|||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
@@ -23,123 +18,23 @@ export function Chromecast({
|
|||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const _client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const _castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
const _sessionManager = GoogleCast.getSessionManager();
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const discoveryAttempts = useRef(0);
|
|
||||||
const maxDiscoveryAttempts = 3;
|
|
||||||
const hasLoggedDevices = useRef(false);
|
|
||||||
|
|
||||||
// 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 {
|
await discoveryManager.startDiscovery();
|
||||||
// Stop any existing discovery first
|
})();
|
||||||
try {
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
await discoveryManager.stopDiscovery();
|
|
||||||
} catch (_e) {
|
|
||||||
// Ignore errors when stopping
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start fresh discovery
|
|
||||||
await discoveryManager.startDiscovery();
|
|
||||||
discoveryAttempts.current = 0; // Reset on success
|
|
||||||
} catch (error) {
|
|
||||||
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
|
|
||||||
|
|
||||||
// Log device changes for debugging - only once per session
|
|
||||||
useEffect(() => {
|
|
||||||
if (devices.length > 0 && !hasLoggedDevices.current) {
|
|
||||||
console.log(
|
|
||||||
"[Chromecast] Found device(s):",
|
|
||||||
devices.map((d) => d.friendlyName || d.deviceId).join(", "),
|
|
||||||
);
|
|
||||||
hasLoggedDevices.current = true;
|
|
||||||
}
|
|
||||||
}, [devices]);
|
|
||||||
|
|
||||||
// Report video progress to Jellyfin server
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!user?.Id ||
|
|
||||||
!mediaStatus ||
|
|
||||||
!mediaStatus.mediaInfo?.contentId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamPosition = mediaStatus.streamPosition || 0;
|
|
||||||
|
|
||||||
// Report every 10 seconds
|
|
||||||
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentId = mediaStatus.mediaInfo.contentId;
|
|
||||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
|
||||||
const isPaused = mediaStatus.playerState === "paused";
|
|
||||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
|
||||||
const isTranscoding = streamUrl.includes("m3u8");
|
|
||||||
|
|
||||||
const progressInfo: PlaybackProgressInfo = {
|
|
||||||
ItemId: contentId,
|
|
||||||
PositionTicks: positionTicks,
|
|
||||||
IsPaused: isPaused,
|
|
||||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
|
||||||
PlaySessionId: contentId,
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlaystateApi(api)
|
|
||||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
|
||||||
.then(() => {
|
|
||||||
lastReportedProgressRef.current = streamPosition;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to report Chromecast progress:", error);
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
mediaStatus?.mediaInfo?.contentId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 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(
|
||||||
@@ -153,11 +48,8 @@ export function Chromecast({
|
|||||||
<Pressable
|
<Pressable
|
||||||
className='mr-4'
|
className='mr-4'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
router.push("/casting-player");
|
else CastContext.showCastDialog();
|
||||||
} else {
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -174,11 +66,8 @@ export function Chromecast({
|
|||||||
className='mr-2'
|
className='mr-2'
|
||||||
background={false}
|
background={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
router.replace("/casting-player" as any);
|
else CastContext.showCastDialog();
|
||||||
} else {
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -191,11 +80,8 @@ export function Chromecast({
|
|||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
router.push("/casting-player");
|
else CastContext.showCastDialog();
|
||||||
} else {
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -176,15 +176,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("URL: ", data?.url, enableH265);
|
console.log("URL: ", data?.url, enableH265);
|
||||||
console.log("[PlayButton] Item before casting:", {
|
|
||||||
Type: item.Type,
|
|
||||||
Id: item.Id,
|
|
||||||
Name: item.Name,
|
|
||||||
ParentIndexNumber: item.ParentIndexNumber,
|
|
||||||
IndexNumber: item.IndexNumber,
|
|
||||||
SeasonId: item.SeasonId,
|
|
||||||
SeriesId: item.SeriesId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
if (!data?.url) {
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
@@ -204,11 +195,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
? item.RunTimeTicks / 10000000
|
? item.RunTimeTicks / 10000000
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
console.log("[PlayButton] Loading media with customData:", {
|
|
||||||
hasCustomData: !!item,
|
|
||||||
customDataType: item.Type,
|
|
||||||
});
|
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
@@ -217,7 +203,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
streamType: MediaStreamType.BUFFERED,
|
streamType: MediaStreamType.BUFFERED,
|
||||||
streamDuration: streamDurationSeconds,
|
streamDuration: streamDurationSeconds,
|
||||||
customData: item,
|
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
? {
|
? {
|
||||||
@@ -276,7 +261,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (isOpeningCurrentlyPlayingMedia) {
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push("/casting-player");
|
CastContext.showExpandedControls();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
@@ -1,191 +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 from "react";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useCasting } from "@/hooks/useCasting";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
formatTime,
|
|
||||||
getPosterUrl,
|
|
||||||
getProtocolIcon,
|
|
||||||
getProtocolName,
|
|
||||||
} from "@/utils/casting/helpers";
|
|
||||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
export const CastingMiniPlayer: React.FC = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const {
|
|
||||||
isConnected,
|
|
||||||
protocol,
|
|
||||||
currentItem,
|
|
||||||
currentDevice,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
isPlaying,
|
|
||||||
togglePlayPause,
|
|
||||||
} = useCasting(null);
|
|
||||||
|
|
||||||
if (!isConnected || !currentItem || !protocol) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const posterUrl = getPosterUrl(
|
|
||||||
api?.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
80,
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
|
|
||||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
||||||
const TAB_BAR_HEIGHT = 49; // Standard tab bar height
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
router.push("/casting-player");
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable onPress={handlePress}>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${progressPercent}%`,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 12,
|
|
||||||
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={getProtocolIcon(protocol)}
|
|
||||||
size={12}
|
|
||||||
color={protocolColor}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: protocolColor,
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentDevice?.name || getProtocolName(protocol)}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTime(progress)} / {formatTime(duration)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Play/Pause button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isConnected && protocol) {
|
|
||||||
togglePlayPause();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={28}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Device Info Sheet
|
|
||||||
* Shows device details, volume control, and disconnect option
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Modal, Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import type { Device } 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: Device | null;
|
|
||||||
onDisconnect: () => Promise<void>;
|
|
||||||
volume?: number;
|
|
||||||
onVolumeChange?: (volume: number) => Promise<void>;
|
|
||||||
showTechnicalInfo?: boolean;
|
|
||||||
connectionQuality?: "excellent" | "good" | "fair" | "poor";
|
|
||||||
bitrate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
device,
|
|
||||||
onDisconnect,
|
|
||||||
volume = 0.5,
|
|
||||||
onVolumeChange,
|
|
||||||
showTechnicalInfo = false,
|
|
||||||
connectionQuality = "good",
|
|
||||||
bitrate,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
const volumeValue = useSharedValue(volume * 100);
|
|
||||||
|
|
||||||
// Sync volume slider with prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
volumeValue.value = volume * 100;
|
|
||||||
}, [volume, 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) => {
|
|
||||||
if (onVolumeChange) {
|
|
||||||
await onVolumeChange(value / 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
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" }}>
|
|
||||||
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 }}>
|
|
||||||
Device Name
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
|
||||||
{device?.friendlyName || device?.deviceId || "Unknown Device"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Connection Quality */}
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 8 }}>
|
|
||||||
Connection Quality
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor:
|
|
||||||
connectionQuality === "excellent"
|
|
||||||
? "#10b981"
|
|
||||||
: connectionQuality === "good"
|
|
||||||
? "#fbbf24"
|
|
||||||
: connectionQuality === "fair"
|
|
||||||
? "#f97316"
|
|
||||||
: "#ef4444",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
connectionQuality === "excellent"
|
|
||||||
? "#10b981"
|
|
||||||
: connectionQuality === "good"
|
|
||||||
? "#fbbf24"
|
|
||||||
: connectionQuality === "fair"
|
|
||||||
? "#f97316"
|
|
||||||
: "#ef4444",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{connectionQuality}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{bitrate && (
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
|
|
||||||
Bitrate: {(bitrate / 1000000).toFixed(1)} Mbps
|
|
||||||
{connectionQuality === "poor" &&
|
|
||||||
" (Low bitrate may cause buffering)"}
|
|
||||||
{connectionQuality === "fair" && " (Moderate quality)"}
|
|
||||||
{connectionQuality === "good" && " (Good quality)"}
|
|
||||||
{connectionQuality === "excellent" && " (Maximum quality)"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{device?.deviceId && showTechnicalInfo && (
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
|
||||||
Device ID
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 14 }}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{device.deviceId}
|
|
||||||
</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 }}>Volume</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 14 }}>
|
|
||||||
{Math.round((volume || 0) * 100)}%
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='volume-low' size={20} color='#999' />
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={volumeValue}
|
|
||||||
minimumValue={useSharedValue(0)}
|
|
||||||
maximumValue={useSharedValue(100)}
|
|
||||||
theme={{
|
|
||||||
disableMinTrackTintColor: "#333",
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: "#a855f7",
|
|
||||||
bubbleBackgroundColor: "#a855f7",
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
console.log(
|
|
||||||
"[Volume] Sliding started",
|
|
||||||
volumeValue.value,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
volumeValue.value = value;
|
|
||||||
console.log("[Volume] Value changed", value);
|
|
||||||
}}
|
|
||||||
onSlidingComplete={handleVolumeComplete}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
|
||||||
disable={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Ionicons name='volume-high' size={20} color='#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' />
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,222 +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, useRef } from "react";
|
|
||||||
import { FlatList, Modal, Pressable, 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 flatListRef = useRef<FlatList>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible && currentItem && episodes.length > 0) {
|
|
||||||
const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id);
|
|
||||||
if (currentIndex !== -1 && flatListRef.current) {
|
|
||||||
// Delay to ensure FlatList is rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: currentIndex,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5, // Center the item
|
|
||||||
});
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [visible, currentItem, episodes]);
|
|
||||||
|
|
||||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
|
||||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSelectEpisode(item);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: isCurrentEpisode ? "#a855f7" : "transparent",
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 68,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{api && item.Id && (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: getPrimaryImageUrl({ api, item }) || undefined,
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(!api || !item.Id) && (
|
|
||||||
<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}. {truncateTitle(item.Name || "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.ProductionYear && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.RunTimeTicks && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{Math.round(item.RunTimeTicks / 600000000)} min
|
|
||||||
</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={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
Episodes
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<FlatList
|
|
||||||
ref={flatListRef}
|
|
||||||
data={episodes}
|
|
||||||
renderItem={renderEpisode}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
onScrollToIndexFailed={(info) => {
|
|
||||||
// Fallback if scroll fails
|
|
||||||
setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: info.index,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5,
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Settings Menu
|
|
||||||
* Allows users to configure audio, subtitles, quality, and playback speed
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
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,
|
|
||||||
MediaSource,
|
|
||||||
SubtitleTrack,
|
|
||||||
} from "@/utils/casting/types";
|
|
||||||
|
|
||||||
interface ChromecastSettingsMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSources: MediaSource[];
|
|
||||||
selectedMediaSource: MediaSource | null;
|
|
||||||
onMediaSourceChange: (source: MediaSource) => void;
|
|
||||||
audioTracks: AudioTrack[];
|
|
||||||
selectedAudioTrack: AudioTrack | null;
|
|
||||||
onAudioTrackChange: (track: AudioTrack) => void;
|
|
||||||
subtitleTracks: SubtitleTrack[];
|
|
||||||
selectedSubtitleTrack: SubtitleTrack | null;
|
|
||||||
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onPlaybackSpeedChange: (speed: number) => void;
|
|
||||||
showTechnicalInfo: boolean;
|
|
||||||
onToggleTechnicalInfo: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
||||||
|
|
||||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
item: _item, // Reserved for future use (technical info display)
|
|
||||||
mediaSources,
|
|
||||||
selectedMediaSource,
|
|
||||||
onMediaSourceChange,
|
|
||||||
audioTracks,
|
|
||||||
selectedAudioTrack,
|
|
||||||
onAudioTrackChange,
|
|
||||||
subtitleTracks,
|
|
||||||
selectedSubtitleTrack,
|
|
||||||
onSubtitleTrackChange,
|
|
||||||
playbackSpeed,
|
|
||||||
onPlaybackSpeedChange,
|
|
||||||
showTechnicalInfo,
|
|
||||||
onToggleTechnicalInfo,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
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()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
Playback Settings
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView>
|
|
||||||
{/* Quality/Media Source */}
|
|
||||||
{renderSectionHeader("Quality", "film-outline", "quality")}
|
|
||||||
{expandedSection === "quality" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{mediaSources.map((source) => (
|
|
||||||
<Pressable
|
|
||||||
key={source.id}
|
|
||||||
onPress={() => {
|
|
||||||
onMediaSourceChange(source);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedMediaSource?.id === source.id
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{source.name}
|
|
||||||
</Text>
|
|
||||||
{source.bitrate && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{Math.round(source.bitrate / 1000000)} Mbps
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedMediaSource?.id === source.id && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio Tracks - only show if more than one track */}
|
|
||||||
{audioTracks.length > 1 &&
|
|
||||||
renderSectionHeader("Audio", "musical-notes", "audio")}
|
|
||||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{audioTracks.map((track) => (
|
|
||||||
<Pressable
|
|
||||||
key={track.index}
|
|
||||||
onPress={() => {
|
|
||||||
onAudioTrackChange(track);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedAudioTrack?.index === track.index
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{track.displayTitle || track.language || "Unknown"}
|
|
||||||
</Text>
|
|
||||||
{track.codec && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{track.codec.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedAudioTrack?.index === track.index && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitle Tracks - only show if subtitles available */}
|
|
||||||
{subtitleTracks.length > 0 &&
|
|
||||||
renderSectionHeader("Subtitles", "text", "subtitles")}
|
|
||||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSubtitleTrackChange(null);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSubtitleTrack === null
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>None</Text>
|
|
||||||
{selectedSubtitleTrack === null && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
{subtitleTracks.map((track) => (
|
|
||||||
<Pressable
|
|
||||||
key={track.index}
|
|
||||||
onPress={() => {
|
|
||||||
onSubtitleTrackChange(track);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSubtitleTrack?.index === track.index
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{track.displayTitle || track.language || "Unknown"}
|
|
||||||
</Text>
|
|
||||||
{track.codec && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{track.codec.toUpperCase()}
|
|
||||||
{track.isForced && " • Forced"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedSubtitleTrack?.index === track.index && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playback Speed */}
|
|
||||||
{renderSectionHeader("Playback Speed", "speedometer", "speed")}
|
|
||||||
{expandedSection === "speed" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{PLAYBACK_SPEEDS.map((speed) => (
|
|
||||||
<Pressable
|
|
||||||
key={speed}
|
|
||||||
onPress={() => {
|
|
||||||
onPlaybackSpeedChange(speed);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
playbackSpeed === speed ? "#2a2a2a" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{speed === 1 ? "Normal" : `${speed}x`}
|
|
||||||
</Text>
|
|
||||||
{playbackSpeed === speed && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Technical Info Toggle */}
|
|
||||||
<Pressable
|
|
||||||
onPress={onToggleTechnicalInfo}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='information-circle' size={20} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
Show Technical Info
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: 15,
|
|
||||||
backgroundColor: showTechnicalInfo ? "#a855f7" : "#333",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
|
|
||||||
padding: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 13,
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</ScrollView>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,169 +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
|
|
||||||
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(
|
|
||||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
|
||||||
if (segments.intro) {
|
|
||||||
return seekFn(segments.intro.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.intro],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipCredits = useCallback(
|
|
||||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
|
||||||
if (segments.credits) {
|
|
||||||
return seekFn(segments.credits.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.credits],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
|
||||||
if (currentSegment?.segment) {
|
|
||||||
return 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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -47,7 +47,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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -250,15 +248,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{/* Media Segment Skip Settings */}
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.segment_skip_settings")}
|
|
||||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
|
||||||
onPress={() => router.push("/settings/segment-skip/page")}
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ 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;
|
||||||
@@ -69,9 +67,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
skipButtonText,
|
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
skipCreditButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
skipCredit,
|
skipCredit,
|
||||||
@@ -140,7 +136,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
|
||||||
@@ -150,7 +146,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={skipCreditButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -50,9 +42,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;
|
||||||
@@ -121,18 +110,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);
|
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -323,122 +300,27 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Fetch all segments for the current item
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
|
||||||
playTimeoutRef.current = setTimeout(() => {
|
|
||||||
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;
|
|
||||||
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) => {
|
||||||
@@ -652,9 +534,7 @@ 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}
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ const formatTranscodeReason = (reason: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||||
({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
|
({
|
||||||
|
showControls,
|
||||||
|
visible,
|
||||||
|
getTechnicalInfo,
|
||||||
|
playMethod,
|
||||||
|
transcodeReasons,
|
||||||
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|||||||
@@ -1,303 +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 {
|
|
||||||
useCastDevice,
|
|
||||||
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);
|
|
||||||
// const { settings } = useSettings(); // TODO: Use for preferences
|
|
||||||
|
|
||||||
// Chromecast hooks
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
|
||||||
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Detect which protocol is active
|
|
||||||
const chromecastConnected = castDevice !== null;
|
|
||||||
// Future: Add detection for other protocols here
|
|
||||||
|
|
||||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
|
||||||
? "chromecast"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const isConnected = chromecastConnected;
|
|
||||||
|
|
||||||
// Update current device
|
|
||||||
useEffect(() => {
|
|
||||||
if (chromecastConnected && castDevice) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isConnected: true,
|
|
||||||
protocol: "chromecast",
|
|
||||||
currentDevice: {
|
|
||||||
id: castDevice.deviceId,
|
|
||||||
name: castDevice.friendlyName || castDevice.deviceId,
|
|
||||||
protocol: "chromecast",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setState((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) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isPlaying: mediaStatus.playerState === "playing",
|
|
||||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
|
||||||
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
|
||||||
isBuffering: mediaStatus.playerState === "buffering",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mediaStatus, activeProtocol]);
|
|
||||||
|
|
||||||
// Chromecast: Sync volume from device
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeProtocol === "chromecast" && mediaStatus?.volume !== undefined) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
volume: mediaStatus.volume,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mediaStatus?.volume, activeProtocol]);
|
|
||||||
|
|
||||||
// Progress reporting to Jellyfin (optimized to skip redundant reports)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
|
|
||||||
|
|
||||||
const reportProgress = () => {
|
|
||||||
const progressSeconds = Math.floor(state.progress / 1000);
|
|
||||||
|
|
||||||
// Skip if progress hasn't changed significantly (less than 5 seconds)
|
|
||||||
if (Math.abs(progressSeconds - lastReportedProgressRef.current) < 5) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastReportedProgressRef.current = progressSeconds;
|
|
||||||
const playStateApi = api ? getPlaystateApi(api) : null;
|
|
||||||
playStateApi
|
|
||||||
?.reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: progressSeconds * 10000000,
|
|
||||||
IsPaused: !state.isPlaying,
|
|
||||||
PlayMethod:
|
|
||||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const interval = setInterval(reportProgress, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
item?.Id,
|
|
||||||
user?.Id,
|
|
||||||
state.progress,
|
|
||||||
state.isPlaying,
|
|
||||||
isConnected,
|
|
||||||
activeProtocol,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Play/Pause controls
|
|
||||||
const play = useCallback(async () => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
await client?.play();
|
|
||||||
}
|
|
||||||
// Future: Add play control for other protocols
|
|
||||||
}, [client, activeProtocol]);
|
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
await client?.pause();
|
|
||||||
}
|
|
||||||
// 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) => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
await client?.seek({ position: positionMs / 1000 });
|
|
||||||
}
|
|
||||||
// Future: Add seek control for other protocols
|
|
||||||
},
|
|
||||||
[client, activeProtocol],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
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: state.progress * 10000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(DEFAULT_CAST_STATE);
|
|
||||||
}, [client, api, item?.Id, user?.Id, state.progress, 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
|
|
||||||
setState((prev) => ({ ...prev, volume: clampedVolume }));
|
|
||||||
|
|
||||||
// Debounce API call
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeDebounceRef.current = setTimeout(async () => {
|
|
||||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
|
||||||
await client.setStreamVolume(clampedVolume).catch((error) => {
|
|
||||||
console.log(
|
|
||||||
"[useCasting] Volume set failed (no session):",
|
|
||||||
error.message,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Future: Add volume control for other protocols
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
[client, activeProtocol],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Controls visibility
|
|
||||||
const showControls = useCallback(() => {
|
|
||||||
setState((prev) => ({ ...prev, showControls: true }));
|
|
||||||
|
|
||||||
if (controlsTimeoutRef.current) {
|
|
||||||
clearTimeout(controlsTimeoutRef.current);
|
|
||||||
}
|
|
||||||
controlsTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (state.isPlaying) {
|
|
||||||
setState((prev) => ({ ...prev, showControls: false }));
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}, [state.isPlaying]);
|
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
|
||||||
setState((prev) => ({ ...prev, showControls: false }));
|
|
||||||
if (controlsTimeoutRef.current) {
|
|
||||||
clearTimeout(controlsTimeoutRef.current);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (progressIntervalRef.current) {
|
|
||||||
clearInterval(progressIntervalRef.current);
|
|
||||||
}
|
|
||||||
if (controlsTimeoutRef.current) {
|
|
||||||
clearTimeout(controlsTimeoutRef.current);
|
|
||||||
}
|
|
||||||
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
|
|
||||||
isChromecastAvailable: true, // Always available via react-native-google-cast
|
|
||||||
// Future: Add availability checks for other protocols
|
|
||||||
|
|
||||||
// Raw clients (for advanced operations)
|
|
||||||
remoteMediaClient: client,
|
|
||||||
|
|
||||||
// Controls
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
togglePlayPause,
|
|
||||||
seek,
|
|
||||||
skipForward,
|
|
||||||
skipBackward,
|
|
||||||
stop,
|
|
||||||
setVolume,
|
|
||||||
showControls,
|
|
||||||
hideControls,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,105 +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(false);
|
|
||||||
|
|
||||||
// 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) return;
|
|
||||||
|
|
||||||
// For Outro segments, prevent seeking past the end
|
|
||||||
if (segmentType === "Outro" && 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],
|
|
||||||
);
|
|
||||||
// Auto-skip logic when mode is 'auto'
|
|
||||||
useEffect(() => {
|
|
||||||
if (skipMode !== "auto" || isPaused) {
|
|
||||||
autoSkipTriggeredRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSegment && !autoSkipTriggeredRef.current) {
|
|
||||||
autoSkipTriggeredRef.current = true;
|
|
||||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSegment) {
|
|
||||||
autoSkipTriggeredRef.current = false;
|
|
||||||
}
|
|
||||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
|
||||||
|
|
||||||
// Return null segment if skip mode is 'none'
|
|
||||||
return {
|
|
||||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
|
||||||
skipSegment,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.7.2",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -24,41 +24,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",
|
|
||||||
"skip_outro": "Skip Outro",
|
|
||||||
"skip_recap": "Skip Recap",
|
|
||||||
"skip_commercial": "Skip Commercial",
|
|
||||||
"skip_preview": "Skip Preview",
|
|
||||||
"error": "Error",
|
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
|
||||||
"client_error": "Client Error",
|
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
|
||||||
"next_episode": "Next Episode",
|
|
||||||
"refresh_tracks": "Refresh Tracks",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Playback State:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Continue Watching",
|
|
||||||
"go_back": "Go Back",
|
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
|
||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
|
||||||
"downloaded_file_yes": "Yes",
|
|
||||||
"downloaded_file_no": "No",
|
|
||||||
"downloaded_file_cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"casting_player": {
|
|
||||||
"buffering": "Buffering...",
|
|
||||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
|
||||||
"connection_quality": {
|
|
||||||
"excellent": "Excellent",
|
|
||||||
"good": "Good",
|
|
||||||
"fair": "Fair",
|
|
||||||
"poor": "Poor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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",
|
||||||
@@ -343,21 +308,6 @@
|
|||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
"auto_play_next_episode": "Auto-play Next Episode",
|
"auto_play_next_episode": "Auto-play Next Episode",
|
||||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||||
"segment_skip_settings": "Segment Skip Settings",
|
|
||||||
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
|
||||||
"skip_intro": "Skip Intro",
|
|
||||||
"skip_intro_description": "Action when intro segment is detected",
|
|
||||||
"skip_outro": "Skip Outro/Credits",
|
|
||||||
"skip_outro_description": "Action when outro/credits segment is detected",
|
|
||||||
"skip_recap": "Skip Recap",
|
|
||||||
"skip_recap_description": "Action when recap segment is detected",
|
|
||||||
"skip_commercial": "Skip Commercial",
|
|
||||||
"skip_commercial_description": "Action when commercial segment is detected",
|
|
||||||
"skip_preview": "Skip Preview",
|
|
||||||
"skip_preview_description": "Action when preview segment is detected",
|
|
||||||
"segment_skip_none": "None",
|
|
||||||
"segment_skip_ask": "Show Skip Button",
|
|
||||||
"segment_skip_auto": "Auto Skip",
|
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -640,6 +590,26 @@
|
|||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No Links"
|
"no_links": "No Links"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Error",
|
||||||
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
|
"client_error": "Client Error",
|
||||||
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
"next_episode": "Next Episode",
|
||||||
|
"refresh_tracks": "Refresh Tracks",
|
||||||
|
"audio_tracks": "Audio Tracks:",
|
||||||
|
"playback_state": "Playback State:",
|
||||||
|
"index": "Index:",
|
||||||
|
"continue_watching": "Continue Watching",
|
||||||
|
"go_back": "Go Back",
|
||||||
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
|
"downloaded_file_yes": "Yes",
|
||||||
|
"downloaded_file_no": "No",
|
||||||
|
"downloaded_file_cancel": "Cancel"
|
||||||
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No Items to Display",
|
||||||
|
|||||||
@@ -134,9 +134,6 @@ export enum VideoPlayer {
|
|||||||
MPV = 0,
|
MPV = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// Audio transcoding mode - controls how surround audio is handled
|
// Audio transcoding mode - controls how surround audio is handled
|
||||||
// This controls server-side transcoding behavior for audio streams.
|
// This controls server-side transcoding behavior for audio streams.
|
||||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||||
@@ -184,12 +181,6 @@ export type Settings = {
|
|||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
// Media segment skip preferences
|
|
||||||
skipIntro: SegmentSkipMode;
|
|
||||||
skipOutro: SegmentSkipMode;
|
|
||||||
skipRecap: SegmentSkipMode;
|
|
||||||
skipCommercial: SegmentSkipMode;
|
|
||||||
skipPreview: SegmentSkipMode;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -275,12 +266,6 @@ export const defaultValues: Settings = {
|
|||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
// Media segment skip defaults
|
|
||||||
skipIntro: "ask",
|
|
||||||
skipOutro: "ask",
|
|
||||||
skipRecap: "ask",
|
|
||||||
skipCommercial: "ask",
|
|
||||||
skipPreview: "ask",
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Helper Functions
|
|
||||||
* Common utilities for casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CastProtocol, ConnectionQuality } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
export const calculateEndingTime = (
|
|
||||||
currentMs: number,
|
|
||||||
durationMs: number,
|
|
||||||
): string => {
|
|
||||||
const remainingMs = durationMs - currentMs;
|
|
||||||
const endTime = new Date(Date.now() + remainingMs);
|
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine connection quality based on bitrate
|
|
||||||
*/
|
|
||||||
export const getConnectionQuality = (bitrate?: number): ConnectionQuality => {
|
|
||||||
if (!bitrate) return "good";
|
|
||||||
const mbps = bitrate / 1000000;
|
|
||||||
|
|
||||||
if (mbps >= 15) return "excellent";
|
|
||||||
if (mbps >= 8) return "good";
|
|
||||||
if (mbps >= 4) return "fair";
|
|
||||||
return "poor";
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (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 bitrate to human-readable string
|
|
||||||
*/
|
|
||||||
export const formatBitrate = (bitrate: number): string => {
|
|
||||||
const mbps = bitrate / 1000000;
|
|
||||||
if (mbps >= 1) {
|
|
||||||
return `${mbps.toFixed(1)} Mbps`;
|
|
||||||
}
|
|
||||||
return `${(bitrate / 1000).toFixed(0)} Kbps`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get protocol display name
|
|
||||||
*/
|
|
||||||
export const getProtocolName = (protocol: CastProtocol): string => {
|
|
||||||
switch (protocol) {
|
|
||||||
case "chromecast":
|
|
||||||
return "Chromecast";
|
|
||||||
// Future: Add cases for other protocols
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get protocol icon name
|
|
||||||
*/
|
|
||||||
export const getProtocolIcon = (
|
|
||||||
protocol: CastProtocol,
|
|
||||||
): "tv" | "logo-apple" => {
|
|
||||||
switch (protocol) {
|
|
||||||
case "chromecast":
|
|
||||||
return "tv";
|
|
||||||
// Future: Add icons for other protocols
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format episode info (e.g., "S1 E1" or "Episode 1")
|
|
||||||
*/
|
|
||||||
export const formatEpisodeInfo = (
|
|
||||||
seasonNumber?: number | null,
|
|
||||||
episodeNumber?: number | null,
|
|
||||||
): string => {
|
|
||||||
if (
|
|
||||||
seasonNumber !== undefined &&
|
|
||||||
seasonNumber !== null &&
|
|
||||||
episodeNumber !== undefined &&
|
|
||||||
episodeNumber !== null
|
|
||||||
) {
|
|
||||||
return `S${seasonNumber} E${episodeNumber}`;
|
|
||||||
}
|
|
||||||
if (episodeNumber !== undefined && episodeNumber !== null) {
|
|
||||||
return `Episode ${episodeNumber}`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we should show next episode countdown
|
|
||||||
*/
|
|
||||||
export const shouldShowNextEpisodeCountdown = (
|
|
||||||
remainingMs: number,
|
|
||||||
hasNextEpisode: boolean,
|
|
||||||
countdownStartSeconds: number,
|
|
||||||
): boolean => {
|
|
||||||
return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000;
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Types and Options
|
|
||||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
|
||||||
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CastProtocol = "chromecast";
|
|
||||||
|
|
||||||
export interface CastDevice {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
protocol: CastProtocol;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CastPlayerState {
|
|
||||||
isConnected: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
currentItem: any | null;
|
|
||||||
currentDevice: CastDevice | null;
|
|
||||||
protocol: CastProtocol | null;
|
|
||||||
progress: number;
|
|
||||||
duration: number;
|
|
||||||
volume: number;
|
|
||||||
showControls: boolean;
|
|
||||||
isBuffering: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CastSegmentData {
|
|
||||||
intro: { start: number; end: number } | null;
|
|
||||||
credits: { start: number; end: number } | null;
|
|
||||||
recap: { start: number; end: number } | null;
|
|
||||||
commercial: Array<{ start: number; end: number }>;
|
|
||||||
preview: Array<{ start: number; end: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
showControls: true,
|
|
||||||
isBuffering: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConnectionQuality = "excellent" | "good" | "fair" | "poor";
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast utility helper functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CONNECTION_QUALITY, type ConnectionQuality } from "./options";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats 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;
|
|
||||||
|
|
||||||
const pad = (num: number) => num.toString().padStart(2, "0");
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
|
||||||
}
|
|
||||||
return `${minutes}:${pad(seconds)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates ending time based on current time and remaining duration
|
|
||||||
*/
|
|
||||||
export const calculateEndingTime = (
|
|
||||||
remainingMs: number,
|
|
||||||
use24Hour = true,
|
|
||||||
): string => {
|
|
||||||
const endTime = new Date(Date.now() + remainingMs);
|
|
||||||
const hours = endTime.getHours();
|
|
||||||
const minutes = endTime.getMinutes();
|
|
||||||
|
|
||||||
if (use24Hour) {
|
|
||||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const period = hours >= 12 ? "PM" : "AM";
|
|
||||||
const displayHours = hours % 12 || 12;
|
|
||||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${period}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines connection quality based on bitrate and latency
|
|
||||||
*/
|
|
||||||
export const getConnectionQuality = (
|
|
||||||
bitrateMbps: number,
|
|
||||||
latencyMs?: number,
|
|
||||||
): ConnectionQuality => {
|
|
||||||
// Prioritize bitrate, but factor in latency if available
|
|
||||||
let effectiveBitrate = bitrateMbps;
|
|
||||||
|
|
||||||
if (latencyMs !== undefined && latencyMs > 200) {
|
|
||||||
effectiveBitrate *= 0.7; // Reduce effective quality for high latency
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveBitrate >= CONNECTION_QUALITY.EXCELLENT.min) {
|
|
||||||
return "EXCELLENT";
|
|
||||||
}
|
|
||||||
if (effectiveBitrate >= CONNECTION_QUALITY.GOOD.min) {
|
|
||||||
return "GOOD";
|
|
||||||
}
|
|
||||||
if (effectiveBitrate >= CONNECTION_QUALITY.FAIR.min) {
|
|
||||||
return "FAIR";
|
|
||||||
}
|
|
||||||
return "POOR";
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if we should show next episode countdown
|
|
||||||
*/
|
|
||||||
export const shouldShowNextEpisodeCountdown = (
|
|
||||||
remainingMs: number,
|
|
||||||
hasNextEpisode: boolean,
|
|
||||||
countdownStartSeconds: number,
|
|
||||||
): boolean => {
|
|
||||||
return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncates long titles with ellipsis
|
|
||||||
*/
|
|
||||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
|
||||||
if (title.length <= maxLength) return title;
|
|
||||||
return `${title.substring(0, maxLength - 3)}...`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats episode info (e.g., "S1 E1" or "Episode 1")
|
|
||||||
*/
|
|
||||||
export const formatEpisodeInfo = (
|
|
||||||
seasonNumber?: number | null,
|
|
||||||
episodeNumber?: number | null,
|
|
||||||
): string => {
|
|
||||||
if (
|
|
||||||
seasonNumber !== undefined &&
|
|
||||||
seasonNumber !== null &&
|
|
||||||
episodeNumber !== undefined &&
|
|
||||||
episodeNumber !== null
|
|
||||||
) {
|
|
||||||
return `S${seasonNumber} E${episodeNumber}`;
|
|
||||||
}
|
|
||||||
if (episodeNumber !== undefined && episodeNumber !== null) {
|
|
||||||
return `Episode ${episodeNumber}`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the appropriate poster URL (season for series, primary for movies)
|
|
||||||
*/
|
|
||||||
export const getPosterUrl = (
|
|
||||||
item: {
|
|
||||||
Type?: string | null;
|
|
||||||
ParentBackdropImageTags?: string[] | null;
|
|
||||||
SeriesId?: string | null;
|
|
||||||
Id?: string | null;
|
|
||||||
},
|
|
||||||
api: { basePath?: string },
|
|
||||||
): string | null => {
|
|
||||||
if (!api.basePath) return null;
|
|
||||||
|
|
||||||
if (item.Type === "Episode" && item.SeriesId) {
|
|
||||||
// Use season poster for episodes
|
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Primary`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use primary image for movies and other types
|
|
||||||
if (item.Id) {
|
|
||||||
return `${api.basePath}/Items/${item.Id}/Images/Primary`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if currently within a segment (intro, credits, etc.)
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast player configuration and constants
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const CHROMECAST_CONSTANTS = {
|
|
||||||
// Timing
|
|
||||||
PROGRESS_REPORT_INTERVAL: 10, // seconds
|
|
||||||
CONTROLS_TIMEOUT: 5000, // ms
|
|
||||||
BUFFERING_THRESHOLD: 10, // seconds of buffer before hiding indicator
|
|
||||||
NEXT_EPISODE_COUNTDOWN_START: 30, // seconds before end
|
|
||||||
CONNECTION_CHECK_INTERVAL: 5000, // ms
|
|
||||||
|
|
||||||
// UI
|
|
||||||
POSTER_WIDTH: 300,
|
|
||||||
POSTER_HEIGHT: 450,
|
|
||||||
MINI_PLAYER_HEIGHT: 80,
|
|
||||||
SKIP_FORWARD_TIME: 15, // seconds (overridden by settings)
|
|
||||||
SKIP_BACKWARD_TIME: 15, // seconds (overridden by settings)
|
|
||||||
|
|
||||||
// Animation
|
|
||||||
ANIMATION_DURATION: 300, // ms
|
|
||||||
BLUR_RADIUS: 10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const CONNECTION_QUALITY = {
|
|
||||||
EXCELLENT: { min: 50, label: "Excellent", icon: "signal" },
|
|
||||||
GOOD: { min: 30, label: "Good", icon: "signal" },
|
|
||||||
FAIR: { min: 15, label: "Fair", icon: "signal" },
|
|
||||||
POOR: { min: 0, label: "Poor", icon: "signal" },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type ConnectionQuality = keyof typeof CONNECTION_QUALITY;
|
|
||||||
|
|
||||||
export interface ChromecastPlayerState {
|
|
||||||
isConnected: boolean;
|
|
||||||
deviceName: string | null;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isPaused: boolean;
|
|
||||||
isStopped: boolean;
|
|
||||||
isBuffering: boolean;
|
|
||||||
progress: number; // milliseconds
|
|
||||||
duration: number; // milliseconds
|
|
||||||
volume: number; // 0-1
|
|
||||||
isMuted: boolean;
|
|
||||||
currentItemId: string | null;
|
|
||||||
connectionQuality: ConnectionQuality;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = {
|
|
||||||
isConnected: false,
|
|
||||||
deviceName: null,
|
|
||||||
isPlaying: false,
|
|
||||||
isPaused: false,
|
|
||||||
isStopped: true,
|
|
||||||
isBuffering: false,
|
|
||||||
progress: 0,
|
|
||||||
duration: 0,
|
|
||||||
volume: 1,
|
|
||||||
isMuted: false,
|
|
||||||
currentItemId: null,
|
|
||||||
connectionQuality: "EXCELLENT",
|
|
||||||
};
|
|
||||||
@@ -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,9 +146,6 @@ 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[] = [];
|
||||||
@@ -219,13 +184,7 @@ const fetchLegacySegments = async (
|
|||||||
console.error("Failed to fetch legacy segments", 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 (
|
||||||
@@ -234,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