Compare commits
11 Commits
refactor-c
...
fix/show-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d3c82ca8 | ||
|
|
801ab275ab | ||
|
|
f7033e7abb | ||
|
|
0d796d01b8 | ||
|
|
46d96d5965 | ||
|
|
7d16e7d5c7 | ||
|
|
ceb9b5a1ae | ||
|
|
1144ff5049 | ||
|
|
4d508a4315 | ||
|
|
915a4febbb | ||
|
|
88163eb531 |
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
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.
|
||||
|
||||
## Main Technologies
|
||||
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `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)
|
||||
- **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
|
||||
- Prefer functional React components with hooks
|
||||
- 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
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- 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
|
||||
- Write self-documenting code with clear intent
|
||||
- Add comments only when code complexity requires explanation
|
||||
|
||||
## API Integration
|
||||
|
||||
@@ -108,18 +85,6 @@ Exemples:
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `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
|
||||
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Factory function to create skip options for a specific segment type
|
||||
* Reduces code duplication across all 5 segment types
|
||||
*/
|
||||
const useSkipOptions = (
|
||||
settingKey:
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview",
|
||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
||||
t: TFunction<"translation", undefined>,
|
||||
) => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
selected: option.value === settings?.[settingKey],
|
||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
||||
);
|
||||
};
|
||||
|
||||
export default function SegmentSkipPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.other.segment_skip_settings"),
|
||||
});
|
||||
}, [navigation, t]);
|
||||
|
||||
const skipIntroOptions = useSkipOptions(
|
||||
"skipIntro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipOutroOptions = useSkipOptions(
|
||||
"skipOutro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipRecapOptions = useSkipOptions(
|
||||
"skipRecap",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipCommercialOptions = useSkipOptions(
|
||||
"skipCommercial",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipPreviewOptions = useSkipOptions(
|
||||
"skipPreview",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={false} className='px-4'>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
subtitle={t("home.settings.other.skip_intro_description")}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipIntroOptions}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
subtitle={t("home.settings.other.skip_outro_description")}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipOutroOptions}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
subtitle={t("home.settings.other.skip_recap_description")}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipRecapOptions}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipCommercialOptions}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
subtitle={t("home.settings.other.skip_preview_description")}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipPreviewOptions}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
|
||||
const SEGMENT_SKIP_OPTIONS = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): Array<{
|
||||
label: string;
|
||||
value: "none" | "ask" | "auto";
|
||||
}> => [
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_auto"),
|
||||
value: "auto",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_ask"),
|
||||
value: "ask",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_none"),
|
||||
value: "none",
|
||||
},
|
||||
];
|
||||
@@ -40,6 +40,8 @@ const Layout = () => {
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarActiveTintColor: "#FFFFFF",
|
||||
tabBarInactiveTintColor: "#9CA3AF",
|
||||
tabBarLabelStyle: {
|
||||
fontSize: TAB_LABEL_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
|
||||
@@ -11,8 +11,6 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
|
||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -104,8 +102,8 @@ export default function TabLayout() {
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||
? (_e) => require("@/assets/icons/list.star.png")
|
||||
: (_e) => ({ sfSymbol: "list.star" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -114,7 +112,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
|
||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||
}}
|
||||
/>
|
||||
@@ -125,8 +123,8 @@ export default function TabLayout() {
|
||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||
? (_e) => require("@/assets/icons/link.png")
|
||||
: (_e) => ({ sfSymbol: "link" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -136,13 +134,11 @@ export default function TabLayout() {
|
||||
tabBarItemHidden: !Platform.isTV,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
|
||||
? (_e) => require("@/assets/icons/gearshape.fill.png")
|
||||
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<CastingMiniPlayer />
|
||||
<CastAutoplayWatcher />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
|
||||
@@ -1,768 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Player Modal
|
||||
* Protocol-agnostic full-screen player for all supported casting protocols
|
||||
*/
|
||||
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastState,
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
||||
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
|
||||
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
|
||||
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
|
||||
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
|
||||
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
||||
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
||||
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
||||
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
||||
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
||||
import { useCasting } from "@/hooks/useCasting";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
||||
import { useCastSelection } from "@/hooks/useCastSelection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { detectCapabilities } from "@/utils/casting/capabilities";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { getPosterUrl } from "@/utils/casting/helpers";
|
||||
import { resolveSelection } from "@/utils/casting/selection";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
export default function CastingPlayerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Chromecast autoplay countdown — watcher hook drives this atom; we render
|
||||
// the overlay here when set, and handle Play-now / Cancel from the user.
|
||||
const castAutoplay = useAtomValue(castAutoplayAtom);
|
||||
const setCastAutoplay = useSetAtom(castAutoplayAtom);
|
||||
|
||||
// Get raw Chromecast state directly - same as old implementation
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const castDevice = useCastDevice();
|
||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
||||
useRemoteMediaClient();
|
||||
|
||||
// Fetch full item data from Jellyfin by ID and derive the effective item
|
||||
const { fetchedItem, currentItem } = useCastPlayerItem({
|
||||
api,
|
||||
user,
|
||||
mediaStatus,
|
||||
});
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentDevice = castDevice?.friendlyName ?? null;
|
||||
|
||||
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
||||
// live-progress interpolation, resume-position tracking, trickplay preview.
|
||||
const {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
||||
|
||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
||||
const castingControls = useCasting(currentItem);
|
||||
const {
|
||||
togglePlayPause,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setVolume,
|
||||
volume,
|
||||
remoteMediaClient,
|
||||
} = currentItem
|
||||
? castingControls
|
||||
: {
|
||||
togglePlayPause: async () => {},
|
||||
skipForward: async () => {},
|
||||
skipBackward: async () => {},
|
||||
setVolume: () => {},
|
||||
volume: 1,
|
||||
remoteMediaClient: null,
|
||||
};
|
||||
|
||||
// Modal states
|
||||
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
||||
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Chapter markers (shown for both episodes and movies).
|
||||
const chapters = currentItem?.Chapters;
|
||||
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
|
||||
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
|
||||
|
||||
// Reload the cast stream with a full selection; resolves true on success.
|
||||
const reloadWithSelection = useCallback(
|
||||
async (selection: CastSelection): Promise<boolean> => {
|
||||
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
||||
console.warn("[Casting Player] Cannot reload - missing required data");
|
||||
return false;
|
||||
}
|
||||
const currentPosition = resumePositionRef.current;
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: currentItem,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: {
|
||||
mediaSourceId: selection.mediaSourceId,
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
maxBitrate: selection.maxBitrate,
|
||||
startPositionMs: currentPosition * 1000,
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to reload stream:",
|
||||
result.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
],
|
||||
);
|
||||
|
||||
const { currentSelection, applySelection } = useCastSelection({
|
||||
currentItem,
|
||||
mediaStatus,
|
||||
reload: reloadWithSelection,
|
||||
});
|
||||
|
||||
// Episode/season cluster: episode list, next episode, season data, loader
|
||||
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
|
||||
useCastEpisodes({
|
||||
api,
|
||||
user,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings,
|
||||
});
|
||||
|
||||
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
|
||||
// cast customData) still describes the previous episode. Used to suppress
|
||||
// episode-dependent secondary UI that would otherwise flash stale data.
|
||||
const isEpisodeTransitioning =
|
||||
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
|
||||
|
||||
// Expose this player to the app-wide remote-control surface while a cast
|
||||
// session is connected. The individual useCasting methods are each
|
||||
// useCallback-wrapped and stable, so depend on them directly rather than on
|
||||
// the whole `castingControls` object literal (rebuilt every render).
|
||||
const {
|
||||
togglePlayPause: castTogglePlayPause,
|
||||
pause: castPause,
|
||||
play: castPlay,
|
||||
stop: castStop,
|
||||
seek: castSeek,
|
||||
setVolume: castSetVolume,
|
||||
} = castingControls;
|
||||
// toggleMute reads the latest volume without making `volume` a useMemo dep.
|
||||
const volumeRef = useRef(volume);
|
||||
volumeRef.current = volume;
|
||||
|
||||
const castController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
playPause: () => {
|
||||
castTogglePlayPause();
|
||||
},
|
||||
pause: () => {
|
||||
castPause();
|
||||
},
|
||||
unpause: () => {
|
||||
castPlay();
|
||||
},
|
||||
stop: () => {
|
||||
castStop();
|
||||
},
|
||||
seek: (positionMs) => {
|
||||
castSeek(positionMs);
|
||||
},
|
||||
next: () => {
|
||||
if (nextEpisode) loadEpisode(nextEpisode);
|
||||
},
|
||||
previous: () => {
|
||||
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
|
||||
if (idx > 0) loadEpisode(episodes[idx - 1]);
|
||||
},
|
||||
setVolume: (level) => {
|
||||
castSetVolume(level);
|
||||
},
|
||||
toggleMute: () => {
|
||||
castSetVolume(volumeRef.current > 0 ? 0 : 1);
|
||||
},
|
||||
}),
|
||||
[
|
||||
castTogglePlayPause,
|
||||
castPause,
|
||||
castPlay,
|
||||
castStop,
|
||||
castSeek,
|
||||
castSetVolume,
|
||||
episodes,
|
||||
nextEpisode,
|
||||
loadEpisode,
|
||||
currentItem?.Id,
|
||||
],
|
||||
);
|
||||
|
||||
useRegisterPlaybackController(
|
||||
castController,
|
||||
castState === CastState.CONNECTED,
|
||||
);
|
||||
|
||||
// The MediaSource currently selected, for deriving its tracks.
|
||||
// Derived from fetchedItem: the slim cast-customData item strips per-source
|
||||
// MediaStreams, so only the full fetched item yields correct track lists.
|
||||
const selectedSource = useMemo(
|
||||
() =>
|
||||
fetchedItem?.MediaSources?.find(
|
||||
(s) => s.Id === currentSelection?.mediaSourceId,
|
||||
) ??
|
||||
fetchedItem?.MediaSources?.[0] ??
|
||||
null,
|
||||
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
|
||||
);
|
||||
|
||||
// Real alternate versions (multi-version items).
|
||||
const availableVersions = useMemo(
|
||||
() =>
|
||||
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
|
||||
id: s.Id ?? `source-${i}`,
|
||||
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
|
||||
})),
|
||||
[fetchedItem?.MediaSources, t],
|
||||
);
|
||||
|
||||
// Quality tiers from the shared ladder, capped to BOTH the device's
|
||||
// capability and the media's own bitrate — a tier above either ceiling
|
||||
// would behave identically to "Max", so it is not offered.
|
||||
const availableQualities = useMemo(() => {
|
||||
const caps = detectCapabilities(castDevice, {
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrate: settings.chromecastMaxBitrate,
|
||||
});
|
||||
const mediaBitrate =
|
||||
selectedSource?.Bitrate ??
|
||||
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
|
||||
Number.POSITIVE_INFINITY;
|
||||
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
|
||||
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
|
||||
}, [
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
selectedSource,
|
||||
fetchedItem?.MediaStreams,
|
||||
]);
|
||||
|
||||
const availableAudioTracks = useMemo(() => {
|
||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||
if (!streams) return [];
|
||||
return streams
|
||||
.filter((stream) => stream.Type === "Audio")
|
||||
.map((stream) => ({
|
||||
index: stream.Index ?? 0,
|
||||
language: stream.Language || "Unknown",
|
||||
displayTitle:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
||||
codec: stream.Codec || "Unknown",
|
||||
channels: stream.Channels,
|
||||
bitrate: stream.BitRate,
|
||||
}));
|
||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||
|
||||
const availableSubtitleTracks = useMemo(() => {
|
||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||
if (!streams) return [];
|
||||
return streams
|
||||
.filter((stream) => stream.Type === "Subtitle")
|
||||
.map((stream) => ({
|
||||
index: stream.Index ?? 0,
|
||||
language: stream.Language || "Unknown",
|
||||
displayTitle:
|
||||
stream.DisplayTitle ||
|
||||
[
|
||||
stream.Language || "Unknown",
|
||||
stream.IsForced ? " (Forced)" : "",
|
||||
stream.Title ? ` - ${stream.Title}` : "",
|
||||
].join(""),
|
||||
codec: stream.Codec || "Unknown",
|
||||
isForced: stream.IsForced || false,
|
||||
isExternal: stream.IsExternal || false,
|
||||
}));
|
||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||
|
||||
// Autoplay overlay's "Play now" — load the queued next episode immediately.
|
||||
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
|
||||
// same start-position derivation) so the cast load is identical regardless
|
||||
// of whether it is triggered by the user or by the countdown timer.
|
||||
const onAutoplayPlayNow = useCallback(async () => {
|
||||
if (!castAutoplay) return;
|
||||
const episode = castAutoplay.nextEpisode;
|
||||
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
|
||||
setCastAutoplay(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: episode,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load next episode (play now):",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reset the autoplay counter on explicit user action.
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load next episode (play now):",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setCastAutoplay(null);
|
||||
}
|
||||
}, [
|
||||
castAutoplay,
|
||||
api,
|
||||
user?.Id,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
updateSettings,
|
||||
setCastAutoplay,
|
||||
]);
|
||||
|
||||
// Poster URL for the queued next episode (mirrors `posterUrl` for the
|
||||
// currently-playing item — same helper, same dimensions).
|
||||
const autoplayPosterUrl = useMemo(() => {
|
||||
if (!castAutoplay || !api?.basePath) return null;
|
||||
const ep = castAutoplay.nextEpisode;
|
||||
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
|
||||
// call the helper with `undefined`. AutoplayCountdown handles null.
|
||||
if (!ep?.Id) return null;
|
||||
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
|
||||
}, [castAutoplay, api?.basePath]);
|
||||
|
||||
// NOTE: Auto-navigation to casting-player is handled by higher-level
|
||||
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
|
||||
// do NOT call router.replace("/casting-player") here because this component
|
||||
// IS the casting-player screen — doing so would cause redundant navigation loops.
|
||||
|
||||
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
||||
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
||||
useChromecastSegments(currentItem, progress * 1000, false);
|
||||
|
||||
// Swipe down to dismiss gesture
|
||||
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
|
||||
router,
|
||||
});
|
||||
|
||||
// Memoize expensive calculations (before early return)
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api?.basePath || !currentItem?.Id) return null;
|
||||
|
||||
// For episodes, use SEASON poster instead of episode poster
|
||||
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
||||
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
||||
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
||||
const seasonImageTag = seasonData.ImageTags?.Primary;
|
||||
return seasonImageTag
|
||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
||||
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
||||
}
|
||||
|
||||
// Fallback to item poster for non-episodes or if season data not loaded
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
260,
|
||||
390,
|
||||
);
|
||||
}, [
|
||||
api?.basePath,
|
||||
currentItem?.Id,
|
||||
currentItem?.Type,
|
||||
seasonData?.Id,
|
||||
seasonData?.ImageTags?.Primary,
|
||||
currentItem?.ImageTags?.Primary,
|
||||
]);
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
|
||||
// Redirect if not connected - check CastState like old implementation
|
||||
useEffect(() => {
|
||||
// Redirect immediately when disconnected or no devices
|
||||
if (
|
||||
castState === CastState.NOT_CONNECTED ||
|
||||
castState === CastState.NO_DEVICES_AVAILABLE
|
||||
) {
|
||||
// Use setTimeout to avoid state update during render
|
||||
const timer = setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, router]);
|
||||
|
||||
// Also redirect if mediaStatus disappears (media ended or stopped)
|
||||
useEffect(() => {
|
||||
if (castState === CastState.CONNECTED && !mediaStatus) {
|
||||
const timer = setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 500); // Small delay to allow for media transitions
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, mediaStatus, router]);
|
||||
|
||||
// Show loading while connecting
|
||||
if (castState === CastState.CONNECTING) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={{ color: "#fff", marginTop: 16 }}>
|
||||
{t("casting_player.connecting")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if not connected or no media playing
|
||||
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "fullScreenModal",
|
||||
animation: "slide_from_bottom",
|
||||
}}
|
||||
/>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{/* Header - Fixed at top */}
|
||||
<CastPlayerHeader
|
||||
insetTop={insets.top}
|
||||
protocolColor={protocolColor}
|
||||
currentDevice={currentDevice}
|
||||
t={t}
|
||||
onDismiss={dismissModal}
|
||||
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
|
||||
onPressSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
|
||||
{/* Title Area — hidden during an episode change to avoid flashing
|
||||
the previous episode's title/season-episode numbers. */}
|
||||
{!isEpisodeTransitioning && (
|
||||
<CastPlayerTitle
|
||||
insetTop={insets.top}
|
||||
currentItem={currentItem}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: insets.top + 160,
|
||||
paddingBottom: insets.bottom + 500,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Poster with buffering overlay — force the overlay during an
|
||||
episode change so the loading state covers the stale poster. */}
|
||||
<CastPlayerPoster
|
||||
posterUrl={posterUrl}
|
||||
isBuffering={isBuffering || isEpisodeTransitioning}
|
||||
currentSegment={currentSegment}
|
||||
skipIntro={skipIntro}
|
||||
skipCredits={skipCredits}
|
||||
skipSegment={skipSegment}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
mediaStatus={mediaStatus}
|
||||
protocolColor={protocolColor}
|
||||
t={t}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed control row - positioned independently. Episode-specific
|
||||
buttons are conditional inside; Stop is always available. */}
|
||||
<CastPlayerEpisodeControls
|
||||
insetBottom={insets.bottom}
|
||||
currentItemId={currentItem.Id}
|
||||
episodes={episodes}
|
||||
nextEpisode={nextEpisode}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
onPressEpisodes={() => setShowEpisodeList(true)}
|
||||
hasChapters={hasChapters}
|
||||
onPressChapters={() => setChapterListVisible(true)}
|
||||
loadEpisode={loadEpisode}
|
||||
router={router}
|
||||
/>
|
||||
|
||||
{/* Fixed bottom controls area */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 10,
|
||||
left: 20,
|
||||
right: 20,
|
||||
zIndex: 98,
|
||||
}}
|
||||
>
|
||||
{/* Progress slider with trickplay preview + time display */}
|
||||
<CastPlayerProgressBar
|
||||
sliderProgress={sliderProgress}
|
||||
sliderMin={sliderMin}
|
||||
sliderMax={sliderMax}
|
||||
isScrubbing={isScrubbing}
|
||||
trickplayTime={trickplayTime}
|
||||
setTrickplayTime={setTrickplayTime}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
calculateTrickplayUrl={calculateTrickplayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
protocolColor={protocolColor}
|
||||
chapters={currentItem?.Chapters}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Playback controls */}
|
||||
<CastPlayerTransportControls
|
||||
isPlaying={isPlaying}
|
||||
togglePlayPause={togglePlayPause}
|
||||
skipBackward={skipBackward}
|
||||
skipForward={skipForward}
|
||||
rewindSkipTime={settings?.rewindSkipTime}
|
||||
forwardSkipTime={settings?.forwardSkipTime}
|
||||
protocolColor={protocolColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Autoplay countdown overlay — bottom-centred above the episode
|
||||
control row and main controls. 320 wide card; centred via
|
||||
left/right:0 + alignItems:"center". */}
|
||||
{castAutoplay && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 280,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
zIndex: 99,
|
||||
}}
|
||||
pointerEvents='box-none'
|
||||
>
|
||||
<AutoplayCountdown
|
||||
nextEpisode={castAutoplay.nextEpisode}
|
||||
posterUrl={autoplayPosterUrl}
|
||||
secondsRemaining={castAutoplay.secondsRemaining}
|
||||
onPlayNow={onAutoplayPlayNow}
|
||||
onCancel={() => setCastAutoplay(null)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ChromecastDeviceSheet
|
||||
visible={showDeviceSheet}
|
||||
onClose={() => setShowDeviceSheet(false)}
|
||||
device={
|
||||
currentDevice && castDevice
|
||||
? { friendlyName: currentDevice }
|
||||
: null
|
||||
}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
// End the casting session and disconnect completely
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
setShowDeviceSheet(false);
|
||||
// Close player immediately after disconnecting
|
||||
setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Error disconnecting from Chromecast:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}}
|
||||
volume={volume}
|
||||
onVolumeChange={async (vol) => {
|
||||
try {
|
||||
setVolume(vol);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to set volume:", error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChromecastEpisodeList
|
||||
visible={showEpisodeList}
|
||||
onClose={() => setShowEpisodeList(false)}
|
||||
currentItem={currentItem}
|
||||
episodes={episodes}
|
||||
api={api}
|
||||
onSelectEpisode={async (episode) => {
|
||||
setShowEpisodeList(false);
|
||||
await loadEpisode(episode);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChapterList
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={progress * 1000}
|
||||
onSeek={(ms) => {
|
||||
remoteMediaClient?.seek({ position: ms / 1000 });
|
||||
}}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
|
||||
<ChromecastSettingsMenu
|
||||
visible={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
versions={availableVersions}
|
||||
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
|
||||
onVersionChange={(id) => {
|
||||
if (!fetchedItem) return;
|
||||
applySelection({
|
||||
...resolveSelection(fetchedItem, { mediaSourceId: id }),
|
||||
maxBitrate: currentSelection?.maxBitrate,
|
||||
});
|
||||
}}
|
||||
qualities={availableQualities}
|
||||
selectedMaxBitrate={currentSelection?.maxBitrate}
|
||||
onQualityChange={(value) => applySelection({ maxBitrate: value })}
|
||||
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
|
||||
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
|
||||
onAudioChange={(index) =>
|
||||
applySelection({ audioStreamIndex: index })
|
||||
}
|
||||
subtitleTracks={
|
||||
isEpisodeTransitioning ? [] : availableSubtitleTracks
|
||||
}
|
||||
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
|
||||
onSubtitleChange={(index) =>
|
||||
applySelection({ subtitleStreamIndex: index })
|
||||
}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
onPlaybackSpeedChange={(speed) => {
|
||||
setCurrentPlaybackSpeed(speed);
|
||||
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -59,10 +60,6 @@ import {
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
|
||||
@@ -277,6 +274,11 @@ export default function DirectPlayerPage() {
|
||||
};
|
||||
|
||||
if (itemId) {
|
||||
setItem(null);
|
||||
setDownloadedItem(null);
|
||||
// Clear the previous episode's stream so the loader gate stays closed
|
||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||
setStream(null);
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
@@ -319,6 +321,12 @@ export default function DirectPlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure item matches the current itemId to avoid race conditions
|
||||
if (item.Id !== itemId) {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
@@ -391,6 +399,7 @@ export default function DirectPlayerPage() {
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
offline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -406,25 +415,39 @@ export default function DirectPlayerPage() {
|
||||
reportPlaybackStart();
|
||||
}, [stream, api, offline]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
playbackManager.reportPlaybackProgress(progressInfo);
|
||||
}
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream.sessionId,
|
||||
await getPlaystateApi(api).reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
PlaySessionId: stream.sessionId,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
progress,
|
||||
offline,
|
||||
revalidateProgressCache,
|
||||
]);
|
||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
@@ -442,9 +465,10 @@ export default function DirectPlayerPage() {
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
return () => {
|
||||
reportPlaybackStopped();
|
||||
beforeRemoveListener();
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
}, [navigation, stop, reportPlaybackStopped]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(():
|
||||
| PlaybackProgressInfo
|
||||
@@ -479,35 +503,6 @@ export default function DirectPlayerPage() {
|
||||
isMuted,
|
||||
]);
|
||||
|
||||
// Declared after currentPlayStateInfo so the dependency array can reference
|
||||
// it without hitting the temporal dead zone.
|
||||
const togglePlay = useCallback(async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
playbackManager.reportPlaybackProgress(progressInfo);
|
||||
}
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
lightHapticFeedback,
|
||||
isPlaying,
|
||||
currentPlayStateInfo,
|
||||
playbackManager,
|
||||
offline,
|
||||
api,
|
||||
]);
|
||||
|
||||
const lastUrlUpdateTime = useSharedValue(0);
|
||||
const wasJustSeeking = useSharedValue(false);
|
||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||
@@ -936,47 +931,6 @@ export default function DirectPlayerPage() {
|
||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||
}, []);
|
||||
|
||||
// App-wide remote control: wrap the player's existing handlers so remote
|
||||
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
|
||||
const playbackController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
// togglePlay flips play/pause and reports progress to the server.
|
||||
playPause: () => {
|
||||
void togglePlay();
|
||||
},
|
||||
pause: () => {
|
||||
pause();
|
||||
},
|
||||
unpause: () => {
|
||||
play();
|
||||
},
|
||||
stop: () => {
|
||||
stop();
|
||||
},
|
||||
// PlaybackController seeks in ms; the player's seek already expects ms.
|
||||
seek: (positionMs: number) => {
|
||||
seek(positionMs);
|
||||
},
|
||||
// The player screen has no episode-loading path of its own — episode
|
||||
// navigation is handled inside <Controls> via router replacement — so
|
||||
// next/previous are honest no-ops here.
|
||||
next: () => {},
|
||||
previous: () => {},
|
||||
// Volume is device-level (react-native-volume-manager); the slider sends
|
||||
// 0-1 while setVolumeCb expects 0-100.
|
||||
setVolume: (level: number) => {
|
||||
void setVolumeCb(level * 100);
|
||||
},
|
||||
toggleMute: () => {
|
||||
void toggleMuteCb();
|
||||
},
|
||||
}),
|
||||
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
|
||||
);
|
||||
|
||||
// Active for the whole lifetime of the player screen; cleared on unmount.
|
||||
useRegisterPlaybackController(playbackController, true);
|
||||
|
||||
// Determine play method based on stream URL and media source
|
||||
const playMethod = useMemo<
|
||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||
@@ -1313,7 +1267,7 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB |
BIN
assets/icons/gearshape.fill.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
assets/icons/link.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
assets/icons/list.star.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/rectangle.stack.fill.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
1
assets/icons/seerr-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,65 +0,0 @@
|
||||
<svg
|
||||
type="certified"
|
||||
viewBox="0 0 80 80"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g transform="translate(2.29, 0)">
|
||||
<path
|
||||
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||
id="Fill-2"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<mask id="mask-2" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||
fill="#FFD700"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||
fill="#FA6E0F"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
<g transform="translate(0, 20.57)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||
fill="#FA3200"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 50 KiB |
1
assets/images/rt_aud_fresh.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
1
assets/images/rt_aud_rotten.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
assets/images/rt_fresh.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
assets/images/rt_rotten.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
assets/images/tmdb_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
122
components/BitRateSheet.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected?: Bitrate | null;
|
||||
inverted?: boolean | null;
|
||||
}
|
||||
|
||||
export const BitrateSheet: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}, [inverted]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.quality")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("item_card.quality")}
|
||||
data={sorted}
|
||||
values={selected ? [selected] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).key || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as Bitrate | undefined;
|
||||
if (chosen) onChange(chosen);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -10,31 +10,36 @@ export type Bitrate = {
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{ key: "Max", value: undefined },
|
||||
{ key: "200 Mb/s", value: 200000000 },
|
||||
{ key: "180 Mb/s", value: 180000000 },
|
||||
{ key: "140 Mb/s", value: 140000000 },
|
||||
{ key: "120 Mb/s", value: 120000000 },
|
||||
{ key: "110 Mb/s", value: 110000000 },
|
||||
{ key: "100 Mb/s", value: 100000000 },
|
||||
{ key: "90 Mb/s", value: 90000000 },
|
||||
{ key: "80 Mb/s", value: 80000000 },
|
||||
{ key: "70 Mb/s", value: 70000000 },
|
||||
{ key: "60 Mb/s", value: 60000000 },
|
||||
{ key: "50 Mb/s", value: 50000000 },
|
||||
{ key: "40 Mb/s", value: 40000000 },
|
||||
{ key: "30 Mb/s", value: 30000000 },
|
||||
{ key: "20 Mb/s", value: 20000000 },
|
||||
{ key: "15 Mb/s", value: 15000000 },
|
||||
{ key: "10 Mb/s", value: 10000000 },
|
||||
{ key: "8 Mb/s", value: 8000000 },
|
||||
{ key: "5 Mb/s", value: 5000000 },
|
||||
{ key: "4 Mb/s", value: 4000000 },
|
||||
{ key: "3 Mb/s", value: 3000000 },
|
||||
{ key: "2 Mb/s", value: 2000000 },
|
||||
{ key: "1 Mb/s", value: 1000000 },
|
||||
{ key: "720 Kb/s", value: 720000 },
|
||||
{ key: "420 Kb/s", value: 420000 },
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
export function Chromecast({
|
||||
@@ -26,136 +18,23 @@ export function Chromecast({
|
||||
background = "transparent",
|
||||
...props
|
||||
}) {
|
||||
// Hooks called for their side effects (keep Chromecast session active)
|
||||
useRemoteMediaClient();
|
||||
useCastDevice();
|
||||
const castState = useCastState();
|
||||
useDevices();
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Connection menu state
|
||||
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
||||
const isConnected = castState === CastState.CONNECTED;
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
||||
const playSessionIdRef = useRef<string | null>(null);
|
||||
const lastContentIdRef = useRef<string | null>(null);
|
||||
const discoveryAttempts = useRef(0);
|
||||
const maxDiscoveryAttempts = 3;
|
||||
|
||||
// Enhanced discovery with retry mechanism - runs once on mount
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
let retryTimeout: NodeJS.Timeout;
|
||||
|
||||
const startDiscoveryWithRetry = async () => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
console.warn("DiscoveryManager is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any existing discovery first
|
||||
try {
|
||||
await discoveryManager.stopDiscovery();
|
||||
} catch {
|
||||
// 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
|
||||
|
||||
// Report video progress to Jellyfin server
|
||||
useEffect(() => {
|
||||
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamPosition = mediaStatus.streamPosition || 0;
|
||||
const playerState = mediaStatus.playerState || null;
|
||||
|
||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
||||
const positionChanged =
|
||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
||||
if (!positionChanged && !stateChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentId = mediaStatus.mediaInfo.contentId;
|
||||
|
||||
// Generate a new PlaySessionId when the content changes
|
||||
if (contentId !== lastContentIdRef.current) {
|
||||
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
|
||||
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
},
|
||||
);
|
||||
lastContentIdRef.current = contentId;
|
||||
}
|
||||
|
||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||
const isPaused = mediaStatus.playerState === "paused";
|
||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
||||
|
||||
const progressInfo: PlaybackProgressInfo = {
|
||||
ItemId: contentId,
|
||||
PositionTicks: positionTicks,
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: playSessionIdRef.current || contentId,
|
||||
};
|
||||
|
||||
getPlaystateApi(api)
|
||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||
.then(() => {
|
||||
lastReportedProgressRef.current = streamPosition;
|
||||
lastReportedPlayerStateRef.current = playerState;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to report Chromecast progress:", error);
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.mediaInfo?.contentUrl,
|
||||
]);
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
@@ -164,92 +43,50 @@ export function Chromecast({
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
// Handle press - show connection menu when connected, otherwise show cast dialog
|
||||
const handlePress = useCallback(() => {
|
||||
if (isConnected) {
|
||||
if (mediaStatus?.currentItemId) {
|
||||
// Media is playing - navigate to full player
|
||||
router.push("/casting-player");
|
||||
} else {
|
||||
// Connected but no media - show connection menu
|
||||
setShowConnectionMenu(true);
|
||||
}
|
||||
} else {
|
||||
// Not connected - show cast dialog
|
||||
CastContext.showCastDialog();
|
||||
}
|
||||
}, [isConnected, mediaStatus?.currentItemId]);
|
||||
|
||||
// Handle disconnect from Chromecast
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
} catch (error) {
|
||||
console.error("[Chromecast] Disconnect error:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<>
|
||||
<Pressable className='mr-4' onPress={handlePress} {...props}>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</Pressable>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
<Pressable
|
||||
className='mr-4'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<>
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={handlePress}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</RoundButton>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoundButton size='large' onPress={handlePress} {...props}>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</RoundButton>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
<RoundButton
|
||||
size='large'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Image
|
||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||
source={require("@/assets/icons/seerr-logo.svg")}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||
<Text className='font-bold mb-1'>Seerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.jellyseerr_feature_description")}
|
||||
</Text>
|
||||
|
||||
@@ -6,8 +6,8 @@ import type {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
@@ -63,7 +57,6 @@ interface PlatformDropdownProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
disabled?: boolean;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
@@ -214,31 +207,10 @@ const PlatformDropdownComponent = ({
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
|
||||
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
|
||||
disabled: isDisabled,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||
const [triggerSize, setTriggerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setTriggerSize((prev) =>
|
||||
prev && prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height },
|
||||
);
|
||||
};
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
@@ -269,32 +241,11 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||
// height depends on the parent while the parent depends on the Host — a
|
||||
// circular dependency that collapses to 0 for any selector nested more than
|
||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||
// fills a concrete box.
|
||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||
return (
|
||||
<View style={triggerSize ?? { opacity: 0 }}>
|
||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||
sizes to the trigger's content rather than to its parent. */}
|
||||
<View
|
||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||
pointerEvents='none'
|
||||
aria-hidden
|
||||
onLayout={handleMeasureTrigger}
|
||||
>
|
||||
<View>
|
||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||
@@ -428,14 +379,8 @@ const PlatformDropdownComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaPlayerState,
|
||||
MediaStreamType,
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
@@ -33,8 +32,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { chromecast } from "../utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -56,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
@@ -109,11 +111,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const options = [
|
||||
t("casting_player.chromecast"),
|
||||
t("casting_player.device"),
|
||||
t("casting_player.cancel"),
|
||||
];
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -122,14 +120,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
// Compare item IDs AND check if media is actually playing (not stopped/idle)
|
||||
const currentContentId = mediaStatus?.mediaInfo?.contentId;
|
||||
const isMediaActive =
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
|
||||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
isMediaActive && currentContentId && currentContentId === item?.Id;
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
@@ -137,8 +130,30 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (state && state !== PlayServicesState.SUCCESS) {
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
} else {
|
||||
if (!api || !user?.Id || !item?.Id) {
|
||||
console.warn("Missing parameters for Chromecast streaming");
|
||||
// Check if user wants H265 for Chromecast
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
|
||||
// Validate required parameters before calling getStreamUrl
|
||||
if (!api) {
|
||||
console.warn("API not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!user?.Id) {
|
||||
console.warn(
|
||||
"User not authenticated for Chromecast streaming",
|
||||
);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!item?.Id) {
|
||||
console.warn("Item not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
@@ -146,37 +161,110 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const startPositionMs =
|
||||
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client,
|
||||
device: castDevice,
|
||||
api,
|
||||
item,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: {
|
||||
// Get a new URL with the Chromecast device profile
|
||||
try {
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
maxBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||
startPositionMs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error("[PlayButton] cast load failed:", result.error);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("URL: ", data?.url, enableH265);
|
||||
|
||||
if (!isOpeningCurrentlyPlayingMedia) {
|
||||
router.push("/casting-player");
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate start time in seconds from playback position
|
||||
const startTimeSeconds =
|
||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||
|
||||
// Calculate stream duration in seconds from runtime
|
||||
const streamDurationSeconds = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -192,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}, [
|
||||
item,
|
||||
client,
|
||||
castDevice,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
|
||||
@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
<Image
|
||||
source={
|
||||
item.CriticRating < 60
|
||||
? require("@/assets/images/rotten-tomatoes.png")
|
||||
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||
? require("@/assets/images/rt_rotten.svg")
|
||||
: require("@/assets/images/rt_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
|
||||
className='mr-1'
|
||||
source={
|
||||
data?.criticsRating === "Rotten"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||
? require("@/assets/images/rt_rotten.svg")
|
||||
: require("@/assets/images/rt_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
|
||||
className='mr-1'
|
||||
source={
|
||||
data?.audienceRating === "Spilled"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||
? require("@/assets/images/rt_aud_rotten.svg")
|
||||
: require("@/assets/images/rt_aud_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
|
||||
iconLeft={
|
||||
<Image
|
||||
className='mr-1'
|
||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||
source={require("@/assets/images/tmdb_logo.svg")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
|
||||
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
|
||||
* fires regardless of which screen is open.
|
||||
*/
|
||||
|
||||
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
|
||||
|
||||
export function CastAutoplayWatcher() {
|
||||
useCastAutoplay();
|
||||
return null;
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Mini Player
|
||||
* Works with all supported casting protocols
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, {
|
||||
SlideInDown,
|
||||
SlideOutDown,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export const CastingMiniPlayer: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
|
||||
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
|
||||
|
||||
// Trickplay support - pass currentItem as BaseItemDto or null
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
currentItem || null,
|
||||
);
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Slider shared values
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
|
||||
// Live progress state that updates every second when playing
|
||||
const [liveProgress, setLiveProgress] = useState(
|
||||
mediaStatus?.streamPosition || 0,
|
||||
);
|
||||
|
||||
// Track baseline for elapsed-time computation
|
||||
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
|
||||
const baselineTimestampRef = useRef(Date.now());
|
||||
|
||||
// Sync live progress with mediaStatus and poll every second when playing
|
||||
useEffect(() => {
|
||||
// Resync baseline whenever mediaStatus reports a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||
baselineTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update based on elapsed real time when playing
|
||||
const interval = setInterval(() => {
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
|
||||
const elapsed =
|
||||
((Date.now() - baselineTimestampRef.current) *
|
||||
(mediaStatus.playbackRate || 1)) /
|
||||
1000;
|
||||
setLiveProgress(baselinePositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||
baselineTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.playbackRate,
|
||||
]);
|
||||
|
||||
const progress = liveProgress * 1000; // Convert to ms
|
||||
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
|
||||
// Update slider max value when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration;
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Sync slider progress with live progress (when not scrubbing)
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress >= 0) {
|
||||
sliderProgress.value = progress;
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
// For episodes, use series poster; for other content, use item poster
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api?.basePath || !currentItem) return null;
|
||||
|
||||
if (
|
||||
currentItem.Type === "Episode" &&
|
||||
currentItem.SeriesId &&
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.SeasonId
|
||||
) {
|
||||
// Build series poster URL using SeriesId and series-level image tag
|
||||
const imageTag = currentItem.SeriesPrimaryImageTag || "";
|
||||
const tagParam = imageTag ? `&tag=${imageTag}` : "";
|
||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
|
||||
}
|
||||
|
||||
// For non-episodes, use item's own poster
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
80,
|
||||
120,
|
||||
);
|
||||
}, [api?.basePath, currentItem]);
|
||||
|
||||
// Hide mini player when:
|
||||
// - No cast device connected
|
||||
// - No media info (currentItem)
|
||||
// - No media status
|
||||
// - Media is stopped (IDLE state)
|
||||
// - Media is unknown state
|
||||
const playerState = mediaStatus?.playerState;
|
||||
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
||||
|
||||
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
||||
|
||||
const handlePress = () => {
|
||||
router.push("/casting-player");
|
||||
};
|
||||
|
||||
const handleTogglePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Pause error:", error);
|
||||
});
|
||||
} else {
|
||||
remoteMediaClient?.play()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Play error:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{/* Interactive progress slider with trickplay */}
|
||||
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 20 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isScrubbing.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Calculate trickplay preview
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
// Update time display for trickplay bubble
|
||||
const progressInSeconds = Math.floor(
|
||||
ticksToSeconds(progressInTicks),
|
||||
);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTrickplayTime({ hours, minutes, seconds });
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isScrubbing.current = false;
|
||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||
const positionSeconds = value / 1000;
|
||||
if (remoteMediaClient && duration > 0) {
|
||||
remoteMediaClient
|
||||
.seek({ position: positionSeconds })
|
||||
.catch((error) => {
|
||||
console.error("[Mini Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={190}
|
||||
/>
|
||||
)}
|
||||
bubbleMaxWidth={190}
|
||||
bubbleWidth={190}
|
||||
bubbleTranslateY={-20}
|
||||
sliderHeight={3}
|
||||
thumbWidth={14}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={handlePress}>
|
||||
{/* Content */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
paddingTop: 6,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 60,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.Name}
|
||||
</Text>
|
||||
{currentItem.SeriesName && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.SeriesName}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={12} color={protocolColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 11,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{castDevice.friendlyName || "Chromecast"}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatTime(progress)} / {formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stop button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Stop error:", error);
|
||||
});
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons name='stop' size={24} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTogglePlayPause();
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Casting Player Episode Controls
|
||||
* Fixed control row: episode list, previous, next, stop.
|
||||
* Episode-specific buttons (list / previous / next) are conditional;
|
||||
* Stop is always rendered so movies still get a Stop button.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { ImperativeRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerEpisodeControlsProps {
|
||||
/** Bottom safe-area inset, used to offset the fixed control row. */
|
||||
insetBottom: number;
|
||||
/** Id of the currently playing episode. */
|
||||
currentItemId: BaseItemDto["Id"];
|
||||
/** Full episode list for the series. */
|
||||
episodes: BaseItemDto[];
|
||||
/** Next episode in the list, or null if none. */
|
||||
nextEpisode: BaseItemDto | null;
|
||||
/** Remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Open the episode list modal. */
|
||||
onPressEpisodes: () => void;
|
||||
/** Whether the current item exposes chapter markers. */
|
||||
hasChapters: boolean;
|
||||
/** Open the chapter list modal. */
|
||||
onPressChapters: () => void;
|
||||
/** Load a different episode on the Chromecast. */
|
||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||
/** Expo Router instance for navigation on stop. */
|
||||
router: ImperativeRouter;
|
||||
}
|
||||
|
||||
export function CastPlayerEpisodeControls({
|
||||
insetBottom,
|
||||
currentItemId,
|
||||
episodes,
|
||||
nextEpisode,
|
||||
remoteMediaClient,
|
||||
onPressEpisodes,
|
||||
hasChapters,
|
||||
onPressChapters,
|
||||
loadEpisode,
|
||||
router,
|
||||
}: CastPlayerEpisodeControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasEpisodeList = episodes.length > 0;
|
||||
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
|
||||
const hasNext = nextEpisode != null;
|
||||
|
||||
// Count of buttons actually rendered (Stop is always rendered).
|
||||
const buttonCount =
|
||||
1 +
|
||||
(hasEpisodeList ? 1 : 0) +
|
||||
(hasChapters ? 1 : 0) +
|
||||
(hasPrevious ? 1 : 0) +
|
||||
(hasNext ? 1 : 0);
|
||||
|
||||
// When Stop is the only button (movies), render it full-width with a label.
|
||||
const isLoneStop = buttonCount === 1;
|
||||
|
||||
// Each button stretches evenly only when the row holds more than one;
|
||||
// a lone Stop button keeps its intrinsic size and stays centered.
|
||||
const buttonStyle = {
|
||||
...(buttonCount > 1 ? { flex: 1 } : {}),
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
flexDirection: "row" as const,
|
||||
justifyContent: "center" as const,
|
||||
alignItems: "center" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insetBottom + 200,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
|
||||
{hasEpisodeList && (
|
||||
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
|
||||
<Ionicons name='list' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
|
||||
{hasChapters && (
|
||||
<Pressable onPress={onPressChapters} style={buttonStyle}>
|
||||
<Ionicons name='bookmarks' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Previous episode button - only rendered when a previous episode exists */}
|
||||
{hasPrevious && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const currentIndex = episodes.findIndex(
|
||||
(ep) => ep.Id === currentItemId,
|
||||
);
|
||||
if (currentIndex > 0) {
|
||||
await loadEpisode(episodes[currentIndex - 1]);
|
||||
}
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Ionicons name='play-skip-back' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Next episode button - only rendered when a next episode exists */}
|
||||
{hasNext && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (nextEpisode) {
|
||||
await loadEpisode(nextEpisode);
|
||||
}
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
// Stop the current media playback (don't disconnect from Chromecast)
|
||||
if (remoteMediaClient) {
|
||||
await remoteMediaClient.stop();
|
||||
}
|
||||
|
||||
// Navigate back/close the player (mini player will disappear since no media is playing)
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Error stopping playback:", error);
|
||||
// Navigate anyway
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
|
||||
>
|
||||
<Ionicons name='stop-circle' size={22} color='white' />
|
||||
{isLoneStop && (
|
||||
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
|
||||
{t("casting_player.stop")}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Casting Player Header
|
||||
* Fixed top bar: dismiss button, connection indicator, settings button.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerHeaderProps {
|
||||
/** Top safe-area inset, used to offset the fixed header. */
|
||||
insetTop: number;
|
||||
/** Streamyfin protocol accent color. */
|
||||
protocolColor: string;
|
||||
/** Friendly name of the connected cast device, or null. */
|
||||
currentDevice: string | null;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
/** Dismiss the casting player modal. */
|
||||
onDismiss: () => void;
|
||||
/** Open the device sheet (connection indicator press). */
|
||||
onPressConnectionIndicator: () => void;
|
||||
/** Open the settings menu. */
|
||||
onPressSettings: () => void;
|
||||
}
|
||||
|
||||
export function CastPlayerHeader({
|
||||
insetTop,
|
||||
protocolColor,
|
||||
currentDevice,
|
||||
t,
|
||||
onDismiss,
|
||||
onPressConnectionIndicator,
|
||||
onPressSettings,
|
||||
}: CastPlayerHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insetTop + 8,
|
||||
left: 20,
|
||||
right: 20,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
|
||||
<Ionicons name='chevron-down' size={32} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Connection indicator */}
|
||||
<Pressable
|
||||
onPress={onPressConnectionIndicator}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: protocolColor,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{currentDevice || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={onPressSettings}
|
||||
style={{ padding: 8, marginRight: -8 }}
|
||||
>
|
||||
<Ionicons name='settings-outline' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Casting Player Poster
|
||||
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import {
|
||||
MediaPlayerState,
|
||||
type MediaStatus,
|
||||
type RemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
|
||||
|
||||
interface CastPlayerPosterProps {
|
||||
/** Poster image URL, or null when unavailable. */
|
||||
posterUrl: string | null;
|
||||
/** Whether the cast media is currently buffering. */
|
||||
isBuffering: boolean;
|
||||
/** The current playback segment (intro/credits/etc.), or null. */
|
||||
currentSegment: ChromecastSegments["currentSegment"];
|
||||
/** Skip the intro segment. */
|
||||
skipIntro: ChromecastSegments["skipIntro"];
|
||||
/** Skip the credits segment. */
|
||||
skipCredits: ChromecastSegments["skipCredits"];
|
||||
/** Skip the current generic segment. */
|
||||
skipSegment: ChromecastSegments["skipSegment"];
|
||||
/** The remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Raw Chromecast media status. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Theme accent color. */
|
||||
protocolColor: string;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerPoster({
|
||||
posterUrl,
|
||||
isBuffering,
|
||||
currentSegment,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
skipSegment,
|
||||
remoteMediaClient,
|
||||
mediaStatus,
|
||||
protocolColor,
|
||||
t,
|
||||
}: CastPlayerPosterProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 280,
|
||||
height: 420,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={64} color='#333' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip intro/credits bar at bottom of poster */}
|
||||
{currentSegment && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (!remoteMediaClient) return;
|
||||
try {
|
||||
const seekFn = async (positionMs: number) => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
||||
) {
|
||||
await remoteMediaClient.seek({
|
||||
position: positionMs / 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (currentSegment.type === "intro") {
|
||||
await skipIntro(seekFn);
|
||||
} else if (currentSegment.type === "credits") {
|
||||
await skipCredits(seekFn);
|
||||
} else {
|
||||
await skipSegment(seekFn);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Skip error:", error);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: protocolColor,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={18} color='white' />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Buffering overlay */}
|
||||
{isBuffering && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color={protocolColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{t("casting_player.buffering")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Casting Player Progress Bar
|
||||
* Progress slider with trickplay preview bubble and current/end time display.
|
||||
*/
|
||||
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface CastPlayerProgressBarProps {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: { current: boolean };
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}) => void;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
/** Current playback progress, in seconds. */
|
||||
progress: number;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
/** Remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Theme color used for the slider track and bubbles. */
|
||||
protocolColor: string;
|
||||
/** Chapter markers for the current item, or null/undefined if none. */
|
||||
chapters?: ChapterInfo[] | null;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerProgressBar({
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
progress,
|
||||
duration,
|
||||
remoteMediaClient,
|
||||
protocolColor,
|
||||
chapters,
|
||||
t,
|
||||
}: CastPlayerProgressBarProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Progress slider with trickplay preview */}
|
||||
<View style={{ marginTop: 8, height: 40 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isScrubbing.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Calculate trickplay preview
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
// Update time display for trickplay bubble
|
||||
const progressInSeconds = Math.floor(
|
||||
ticksToSeconds(progressInTicks),
|
||||
);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTrickplayTime({ hours, minutes, seconds });
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isScrubbing.current = false;
|
||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||
const positionSeconds = value / 1000;
|
||||
if (remoteMediaClient && duration > 0) {
|
||||
remoteMediaClient
|
||||
.seek({ position: positionSeconds })
|
||||
.catch((error) => {
|
||||
console.error("[Casting Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={220}
|
||||
/>
|
||||
)}
|
||||
bubbleMaxWidth={220}
|
||||
bubbleWidth={220}
|
||||
bubbleTranslateY={-20}
|
||||
sliderHeight={6}
|
||||
thumbWidth={16}
|
||||
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
|
||||
/>
|
||||
<ChapterTicks
|
||||
markers={chapterMarkers(chapters, duration * 1000)}
|
||||
height={4}
|
||||
color='#cccccc'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time display */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(progress * 1000)}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{t("casting_player.ending_at", {
|
||||
time: calculateEndingTime(progress * 1000, duration * 1000),
|
||||
})}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(duration * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Casting Player Title Area
|
||||
* Fixed title bar: item title and optional grey episode/season info.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/casting/helpers";
|
||||
|
||||
interface CastPlayerTitleProps {
|
||||
/** Top safe-area inset, used to offset the fixed title area. */
|
||||
insetTop: number;
|
||||
/** The currently playing item. */
|
||||
currentItem: BaseItemDto;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerTitle({
|
||||
insetTop,
|
||||
currentItem,
|
||||
t,
|
||||
}: CastPlayerTitleProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insetTop + 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 95,
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
|
||||
</Text>
|
||||
|
||||
{/* Grey episode/season info */}
|
||||
{currentItem.Type === "Episode" &&
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.IndexNumber !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{t("casting_player.season_episode_format", {
|
||||
season: currentItem.ParentIndexNumber,
|
||||
episode: currentItem.IndexNumber,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Casting Player Transport Controls
|
||||
* Playback transport row: rewind, play/pause, forward.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerTransportControlsProps {
|
||||
/** Whether playback is currently playing. */
|
||||
isPlaying: boolean;
|
||||
/** Toggle play/pause on the Chromecast. */
|
||||
togglePlayPause: () => Promise<void>;
|
||||
/** Skip backward by the given number of seconds. */
|
||||
skipBackward: (seconds: number) => Promise<void>;
|
||||
/** Skip forward by the given number of seconds. */
|
||||
skipForward: (seconds: number) => Promise<void>;
|
||||
/** Configured rewind skip time in seconds, shown on the rewind button. */
|
||||
rewindSkipTime: number | null | undefined;
|
||||
/** Configured forward skip time in seconds, shown on the forward button. */
|
||||
forwardSkipTime: number | null | undefined;
|
||||
/** Accent color used for the play/pause button background. */
|
||||
protocolColor: string;
|
||||
}
|
||||
|
||||
export function CastPlayerTransportControls({
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
skipBackward,
|
||||
skipForward,
|
||||
rewindSkipTime,
|
||||
forwardSkipTime,
|
||||
protocolColor,
|
||||
}: CastPlayerTransportControlsProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 32,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{/* Rewind (use settings) */}
|
||||
<Pressable
|
||||
onPress={() => skipBackward(rewindSkipTime ?? 10)}
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={48}
|
||||
color='white'
|
||||
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
||||
/>
|
||||
{rewindSkipTime != null && (
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{rewindSkipTime}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Pressable
|
||||
onPress={togglePlayPause}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={36}
|
||||
color='white'
|
||||
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Forward (use settings) */}
|
||||
<Pressable
|
||||
onPress={() => skipForward(forwardSkipTime ?? 10)}
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='refresh-outline' size={48} color='white' />
|
||||
{forwardSkipTime != null && (
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{forwardSkipTime}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Shared scrub-preview bubble for the casting progress bars.
|
||||
*
|
||||
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
|
||||
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
|
||||
* component therefore does NO horizontal positioning — it only anchors itself
|
||||
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
|
||||
*/
|
||||
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { formatTrickplayTime } from "@/utils/casting/helpers";
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface CastTrickplayBubbleProps {
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
/** Scrub time to display. */
|
||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||
/** Trickplay tile width in px (220 main player, 140 mini-player). */
|
||||
tileWidth: number;
|
||||
}
|
||||
|
||||
export function CastTrickplayBubble({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
trickplayTime,
|
||||
tileWidth,
|
||||
}: CastTrickplayBubbleProps) {
|
||||
const timeText = (
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
}}
|
||||
>
|
||||
{formatTrickplayTime(trickplayTime)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Anchored to the bottom of the slider-positioned container, growing upward,
|
||||
// and filling the container width (left/right: 0) so it stays centred on the
|
||||
// thumb. No horizontal maths here — the slider owns horizontal placement.
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{timeText}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{timeText}
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
||||
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeArea = useControlsSafeAreaInsets();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
@@ -79,7 +81,17 @@ function ChapterListComponent({
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
marginLeft: safeArea.left,
|
||||
marginRight: safeArea.right,
|
||||
paddingBottom: safeArea.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Chromecast Connection Menu
|
||||
* Shows device info, volume control, and disconnect option
|
||||
* Simple menu for when connected but not actively controlling playback
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastDevice, useCastSession } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastConnectionMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDisconnect?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastConnectionMenu: React.FC<
|
||||
ChromecastConnectionMenuProps
|
||||
> = ({ visible, onClose, onDisconnect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const castDevice = useCastDevice();
|
||||
const castSession = useCastSession();
|
||||
|
||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
||||
const [displayVolume, setDisplayVolume] = useState(50);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isMutedRef = useRef(false);
|
||||
const volumeValue = useSharedValue(50);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(50);
|
||||
|
||||
const protocolColor = "#a855f7";
|
||||
|
||||
// Get initial volume and mute state when menu opens
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial states
|
||||
const fetchInitialState = async () => {
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
fetchInitialState();
|
||||
|
||||
// Poll for external volume changes (physical buttons) - only when not sliding
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
// Only update if external change detected (not our own change)
|
||||
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
if (muted !== isMutedRef.current) {
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, 1000); // Poll less frequently
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
// Volume change during sliding - update display only, don't call API
|
||||
const handleVolumeChange = useCallback((value: number) => {
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
}, []);
|
||||
|
||||
// Volume change complete - call API
|
||||
const handleVolumeComplete = useCallback(
|
||||
async (value: number) => {
|
||||
isSliding.current = false;
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
lastSetVolume.current = rounded;
|
||||
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(rounded / 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Volume error:", error);
|
||||
}
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMute = !isMuted;
|
||||
await castSession.setMute(newMute);
|
||||
isMutedRef.current = newMute;
|
||||
setIsMuted(newMute);
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Mute error:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Disconnect
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
if (onDisconnect) {
|
||||
await onDisconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Disconnect error:", error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [onDisconnect, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with device name */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={20} color='white' />
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{castDevice?.friendlyName || t("casting_player.chromecast")}
|
||||
</Text>
|
||||
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
||||
{t("casting_player.connected")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Volume Control */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("casting_player.volume")}
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? protocolColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isSliding.current = true;
|
||||
}}
|
||||
onValueChange={async (value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
// Unmute when adjusting volume - use ref to avoid
|
||||
// stale closure and prevent repeated async calls
|
||||
if (isMutedRef.current) {
|
||||
isMutedRef.current = false;
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession?.setMute(false);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
"[ChromecastConnectionMenu] Failed to unmute:",
|
||||
error,
|
||||
);
|
||||
isMutedRef.current = true;
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSlidingComplete={handleVolumeComplete}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
style={{
|
||||
backgroundColor: protocolColor,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='power' size={20} color='white' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
||||
>
|
||||
{t("casting_player.disconnect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* Chromecast Device Info Sheet
|
||||
* Shows device details, volume control, and disconnect option
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastSession } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastDeviceSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
device: { friendlyName?: string } | null;
|
||||
onDisconnect: () => Promise<void>;
|
||||
volume?: number;
|
||||
onVolumeChange?: (volume: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
device,
|
||||
onDisconnect,
|
||||
volume = 0.5,
|
||||
onVolumeChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
||||
const volumeValue = useSharedValue(volume * 100);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const castSession = useCastSession();
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(Math.round(volume * 100));
|
||||
|
||||
// Sync volume slider with prop changes (updates from physical buttons)
|
||||
// Skip updates while user is actively sliding to avoid overwriting drag
|
||||
useEffect(() => {
|
||||
if (isSliding.current) return;
|
||||
volumeValue.value = volume * 100;
|
||||
setDisplayVolume(Math.round(volume * 100));
|
||||
}, [volume, volumeValue]);
|
||||
|
||||
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial mute state
|
||||
castSession
|
||||
.isMute()
|
||||
.then(setIsMuted)
|
||||
.catch(() => {});
|
||||
|
||||
// Poll CastSession for device volume and mute state (only when not sliding)
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const deviceVolume = await castSession.getVolume();
|
||||
if (deviceVolume !== undefined) {
|
||||
const volumePercent = Math.round(deviceVolume * 100);
|
||||
// Only update if external change (physical buttons)
|
||||
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(volumePercent);
|
||||
volumeValue.value = volumePercent;
|
||||
lastSetVolume.current = volumePercent;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mute state
|
||||
const muteState = await castSession.isMute();
|
||||
setIsMuted(muteState);
|
||||
} catch {
|
||||
// Ignore errors - device might be disconnected
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
await onDisconnect();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeComplete = async (value: number) => {
|
||||
const newVolume = value / 100;
|
||||
setDisplayVolume(Math.round(value));
|
||||
try {
|
||||
// Use CastSession.setVolume for DEVICE volume control
|
||||
// This works even when no media is playing, unlike setStreamVolume
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
} else if (onVolumeChange) {
|
||||
// Fallback to prop method if session not available
|
||||
await onVolumeChange(newVolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error setting volume:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced volume update during sliding for smooth live feedback
|
||||
const handleVolumeChange = useCallback(
|
||||
(value: number) => {
|
||||
setDisplayVolume(Math.round(value));
|
||||
|
||||
// Debounce the API call to avoid too many requests
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
const newVolume = value / 100;
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during sliding
|
||||
}
|
||||
}, 150); // 150ms debounce
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute state
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMuteState = !isMuted;
|
||||
await castSession.setMute(newMuteState);
|
||||
setIsMuted(newMuteState);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error toggling mute:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
||||
>
|
||||
{t("casting_player.chromecast")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
{t("casting_player.device_name")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
||||
>
|
||||
{device?.friendlyName || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Volume control */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("casting_player.volume")}
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Mute button */}
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
||||
bubbleBackgroundColor: "#a855f7",
|
||||
}}
|
||||
onSlidingStart={async () => {
|
||||
isSliding.current = true;
|
||||
// Auto-unmute when user starts adjusting volume
|
||||
if (isMuted && castSession) {
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession.setMute(false);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Failed to unmute:", error);
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isSliding.current = false;
|
||||
lastSetVolume.current = Math.round(value);
|
||||
handleVolumeComplete(value);
|
||||
}}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={{
|
||||
backgroundColor: "#a855f7",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: isDisconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='power'
|
||||
size={20}
|
||||
color='white'
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{isDisconnecting
|
||||
? t("casting_player.disconnecting")
|
||||
: t("casting_player.stop_casting")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* Episode List for Chromecast Player
|
||||
* Displays list of episodes for TV shows with thumbnails
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/casting/helpers";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface ChromecastEpisodeListProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentItem: BaseItemDto | null;
|
||||
episodes: BaseItemDto[];
|
||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
||||
api: Api | null;
|
||||
}
|
||||
|
||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentItem,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
api,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const scrollRetryCountRef = useRef(0);
|
||||
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const MAX_SCROLL_RETRIES = 3;
|
||||
|
||||
// Cleanup pending retry timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
scrollRetryTimeoutRef.current = null;
|
||||
}
|
||||
scrollRetryCountRef.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get unique seasons from episodes
|
||||
const seasons = useMemo(() => {
|
||||
const seasonSet = new Set<number>();
|
||||
for (const ep of episodes) {
|
||||
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
||||
seasonSet.add(ep.ParentIndexNumber);
|
||||
}
|
||||
}
|
||||
return Array.from(seasonSet).sort((a, b) => a - b);
|
||||
}, [episodes]);
|
||||
|
||||
// Filter episodes by selected season and exclude virtual episodes
|
||||
const filteredEpisodes = useMemo(() => {
|
||||
let eps = episodes;
|
||||
|
||||
// Filter by season if selected
|
||||
if (selectedSeason !== null) {
|
||||
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
||||
}
|
||||
|
||||
// Filter out virtual episodes (episodes without actual video files)
|
||||
// LocationType === "Virtual" means the episode doesn't have a media file
|
||||
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
||||
|
||||
return eps;
|
||||
}, [episodes, selectedSeason]);
|
||||
|
||||
// Set initial season to current episode's season
|
||||
useEffect(() => {
|
||||
if (currentItem?.ParentIndexNumber !== undefined) {
|
||||
setSelectedSeason(currentItem.ParentIndexNumber);
|
||||
}
|
||||
}, [currentItem]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset retry counter when visibility or data changes
|
||||
scrollRetryCountRef.current = 0;
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (visible && currentItem && filteredEpisodes.length > 0) {
|
||||
const currentIndex = filteredEpisodes.findIndex(
|
||||
(ep) => ep.Id === currentItem.Id,
|
||||
);
|
||||
if (currentIndex !== -1 && flatListRef.current) {
|
||||
// Delay to ensure FlatList is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: currentIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5, // Center the item
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [visible, currentItem, filteredEpisodes]);
|
||||
|
||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSelectEpisode(item);
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
padding: 12,
|
||||
// Translucent (not solid) purple so the dark base shows through and
|
||||
// the row's text — incl. the purple S:E label — stays readable. The
|
||||
// play-circle icon also marks the current episode.
|
||||
backgroundColor: isCurrentEpisode
|
||||
? "rgba(168, 85, 247, 0.25)"
|
||||
: "transparent",
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const imageUrl =
|
||||
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={32} color='#333' />
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Episode info */}
|
||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
|
||||
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
|
||||
</Text>
|
||||
{item.Overview && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
||||
{item.ParentIndexNumber !== undefined &&
|
||||
item.IndexNumber !== undefined && (
|
||||
<Text
|
||||
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
||||
>
|
||||
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
||||
{String(item.IndexNumber).padStart(2, "0")}
|
||||
</Text>
|
||||
)}
|
||||
{item.ProductionYear && (
|
||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
)}
|
||||
{item.RunTimeTicks && (
|
||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||
{Math.round(item.RunTimeTicks / 600000000)}{" "}
|
||||
{t("casting_player.minutes_short")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCurrentEpisode && (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-circle' size={24} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: insets.top,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: seasons.length > 1 ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.episodes")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Season selector */}
|
||||
{seasons.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8 }}
|
||||
>
|
||||
{seasons.map((season) => (
|
||||
<Pressable
|
||||
key={season}
|
||||
onPress={() => setSelectedSeason(season)}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor:
|
||||
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: selectedSeason === season ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{t("casting_player.season", { number: season })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Episode list */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={filteredEpisodes}
|
||||
renderItem={renderEpisode}
|
||||
keyExtractor={(item, index) => item.Id || `episode-${index}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// Bounded retry for scroll failures
|
||||
if (
|
||||
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
|
||||
info.index >= filteredEpisodes.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scrollRetryCountRef.current += 1;
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
scrollRetryTimeoutRef.current = setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: info.index,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,304 +0,0 @@
|
||||
/**
|
||||
* Chromecast Settings Menu
|
||||
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
|
||||
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
|
||||
|
||||
export interface VersionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface QualityOption {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
}
|
||||
|
||||
interface ChromecastSettingsMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
versions: VersionOption[];
|
||||
selectedVersionId: string;
|
||||
onVersionChange: (id: string) => void;
|
||||
qualities: QualityOption[];
|
||||
selectedMaxBitrate: number | undefined;
|
||||
onQualityChange: (value: number | undefined) => void;
|
||||
audioTracks: AudioTrack[];
|
||||
selectedAudioIndex: number;
|
||||
onAudioChange: (index: number) => void;
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
/** -1 = subtitles off. */
|
||||
selectedSubtitleIndex: number;
|
||||
onSubtitleChange: (index: number) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const ACCENT = "#a855f7";
|
||||
|
||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
versions,
|
||||
selectedVersionId,
|
||||
onVersionChange,
|
||||
qualities,
|
||||
selectedMaxBitrate,
|
||||
onQualityChange,
|
||||
audioTracks,
|
||||
selectedAudioIndex,
|
||||
onAudioChange,
|
||||
subtitleTracks,
|
||||
selectedSubtitleIndex,
|
||||
onSubtitleChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSection(expandedSection === section ? null : section);
|
||||
};
|
||||
|
||||
const renderSectionHeader = (
|
||||
title: string,
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
sectionKey: string,
|
||||
) => (
|
||||
<Pressable
|
||||
onPress={() => toggleSection(sectionKey)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<Ionicons name={icon} size={20} color='white' />
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color='#999'
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const renderRow = (
|
||||
key: string | number,
|
||||
label: string,
|
||||
sublabel: string | null,
|
||||
selected: boolean,
|
||||
onPress: () => void,
|
||||
) => (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => {
|
||||
onPress();
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor: selected ? "#2a2a2a" : "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
|
||||
{sublabel ? (
|
||||
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
|
||||
{sublabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "80%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.playback_settings")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
{/* Version — only when the item has more than one MediaSource */}
|
||||
{versions.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.version"),
|
||||
"albums-outline",
|
||||
"version",
|
||||
)}
|
||||
{versions.length > 1 && expandedSection === "version" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{versions.map((v) =>
|
||||
renderRow(
|
||||
v.id,
|
||||
v.name,
|
||||
null,
|
||||
v.id === selectedVersionId,
|
||||
() => onVersionChange(v.id),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Quality (bitrate cap) */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.quality"),
|
||||
"film-outline",
|
||||
"quality",
|
||||
)}
|
||||
{expandedSection === "quality" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{qualities.map((q) =>
|
||||
renderRow(
|
||||
q.key,
|
||||
q.key,
|
||||
null,
|
||||
q.value === selectedMaxBitrate,
|
||||
() => onQualityChange(q.value),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio — only when more than one track */}
|
||||
{audioTracks.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.audio"),
|
||||
"musical-notes",
|
||||
"audio",
|
||||
)}
|
||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{audioTracks.map((track) =>
|
||||
renderRow(
|
||||
track.index,
|
||||
track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown"),
|
||||
track.codec ? track.codec.toUpperCase() : null,
|
||||
track.index === selectedAudioIndex,
|
||||
() => onAudioChange(track.index),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitles */}
|
||||
{subtitleTracks.length > 0 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.subtitles"),
|
||||
"text",
|
||||
"subtitles",
|
||||
)}
|
||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{renderRow(
|
||||
"off",
|
||||
t("casting_player.none"),
|
||||
null,
|
||||
selectedSubtitleIndex < 0,
|
||||
() => onSubtitleChange(-1),
|
||||
)}
|
||||
{subtitleTracks.map((track) =>
|
||||
renderRow(
|
||||
track.index,
|
||||
track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown"),
|
||||
[
|
||||
track.codec ? track.codec.toUpperCase() : "",
|
||||
track.isForced ? t("casting_player.forced") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ") || null,
|
||||
track.index === selectedSubtitleIndex,
|
||||
() => onSubtitleChange(track.index),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Playback speed */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.playback_speed"),
|
||||
"speedometer",
|
||||
"speed",
|
||||
)}
|
||||
{expandedSection === "speed" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{PLAYBACK_SPEEDS.map((speed) =>
|
||||
renderRow(
|
||||
speed,
|
||||
speed === 1 ? t("casting_player.normal") : `${speed}x`,
|
||||
null,
|
||||
Math.abs(playbackSpeed - speed) < 0.01,
|
||||
() => onPlaybackSpeedChange(speed),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
||||
* Integrates with autoskip API for segment detection
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { isWithinSegment } from "@/utils/casting/helpers";
|
||||
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
|
||||
export const useChromecastSegments = (
|
||||
item: BaseItemDto | null,
|
||||
currentProgressMs: number,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Fetch segments from autoskip API
|
||||
const { data: segmentData } = useSegments(
|
||||
item?.Id || "",
|
||||
isOffline,
|
||||
undefined, // downloadedFiles parameter
|
||||
api,
|
||||
);
|
||||
|
||||
// Parse segments into usable format
|
||||
const segments = useMemo<ChromecastSegmentData>(() => {
|
||||
if (!segmentData) {
|
||||
return {
|
||||
intro: null,
|
||||
credits: null,
|
||||
recap: null,
|
||||
commercial: [],
|
||||
preview: [],
|
||||
};
|
||||
}
|
||||
|
||||
const intro =
|
||||
segmentData.introSegments && segmentData.introSegments.length > 0
|
||||
? {
|
||||
start: segmentData.introSegments[0].startTime,
|
||||
end: segmentData.introSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const credits =
|
||||
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
||||
? {
|
||||
start: segmentData.creditSegments[0].startTime,
|
||||
end: segmentData.creditSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const recap =
|
||||
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
||||
? {
|
||||
start: segmentData.recapSegments[0].startTime,
|
||||
end: segmentData.recapSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
||||
start: seg.startTime,
|
||||
end: seg.endTime,
|
||||
}));
|
||||
|
||||
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
||||
start: seg.startTime,
|
||||
end: seg.endTime,
|
||||
}));
|
||||
|
||||
return { intro, credits, recap, commercial, preview };
|
||||
}, [segmentData]);
|
||||
|
||||
// Check which segment we're currently in
|
||||
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
|
||||
// before comparing with segment times (which are in seconds from the autoskip API)
|
||||
const currentSegment = useMemo(() => {
|
||||
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
||||
return { type: "intro" as const, segment: segments.intro };
|
||||
}
|
||||
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
||||
return { type: "credits" as const, segment: segments.credits };
|
||||
}
|
||||
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
||||
return { type: "recap" as const, segment: segments.recap };
|
||||
}
|
||||
for (const commercial of segments.commercial) {
|
||||
if (isWithinSegment(currentProgressMs, commercial)) {
|
||||
return { type: "commercial" as const, segment: commercial };
|
||||
}
|
||||
}
|
||||
for (const preview of segments.preview) {
|
||||
if (isWithinSegment(currentProgressMs, preview)) {
|
||||
return { type: "preview" as const, segment: preview };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [currentProgressMs, segments]);
|
||||
|
||||
// Skip functions
|
||||
const skipIntro = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.intro) {
|
||||
await seekFn(segments.intro.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.intro],
|
||||
);
|
||||
|
||||
const skipCredits = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.credits) {
|
||||
await seekFn(segments.credits.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.credits],
|
||||
);
|
||||
|
||||
const skipSegment = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (currentSegment?.segment) {
|
||||
await seekFn(currentSegment.segment.end * 1000);
|
||||
}
|
||||
},
|
||||
[currentSegment],
|
||||
);
|
||||
|
||||
// Auto-skip logic based on settings
|
||||
const shouldAutoSkip = useMemo(() => {
|
||||
if (!currentSegment) return false;
|
||||
|
||||
switch (currentSegment.type) {
|
||||
case "intro":
|
||||
return settings?.skipIntro === "auto";
|
||||
case "credits":
|
||||
return settings?.skipOutro === "auto";
|
||||
case "recap":
|
||||
return settings?.skipRecap === "auto";
|
||||
case "commercial":
|
||||
return settings?.skipCommercial === "auto";
|
||||
case "preview":
|
||||
return settings?.skipPreview === "auto";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
currentSegment,
|
||||
settings?.skipIntro,
|
||||
settings?.skipOutro,
|
||||
settings?.skipRecap,
|
||||
settings?.skipCommercial,
|
||||
settings?.skipPreview,
|
||||
]);
|
||||
|
||||
return {
|
||||
segments,
|
||||
currentSegment,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
skipSegment,
|
||||
shouldAutoSkip,
|
||||
hasIntro: !!segments.intro,
|
||||
hasCredits: !!segments.credits,
|
||||
};
|
||||
};
|
||||
@@ -60,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
|
||||
return (
|
||||
<MoreMoviesWithActor
|
||||
key={`${person.Id}-${idx}`}
|
||||
key={person.Id}
|
||||
currentItem={item}
|
||||
actorId={person.Id}
|
||||
actorName={person.Name}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Player-agnostic "next episode" countdown card. The parent owns the timer and
|
||||
* positioning — this component only renders the next episode's poster, title,
|
||||
* the remaining seconds, and the Play-now / Cancel actions.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface AutoplayCountdownProps {
|
||||
/** The episode that will play next. */
|
||||
nextEpisode: BaseItemDto;
|
||||
/** Poster image URL for the next episode, or null. */
|
||||
posterUrl: string | null;
|
||||
/** Seconds left before the next episode plays. */
|
||||
secondsRemaining: number;
|
||||
/** Play the next episode immediately. */
|
||||
onPlayNow: () => void;
|
||||
/** Cancel autoplay — the next episode will not play. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AutoplayCountdown({
|
||||
nextEpisode,
|
||||
posterUrl,
|
||||
secondsRemaining,
|
||||
onPlayNow,
|
||||
onCancel,
|
||||
}: AutoplayCountdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
width: 320,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "rgba(20, 20, 20, 0.94)",
|
||||
}}
|
||||
>
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: 62, height: 93, borderRadius: 6 }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, justifyContent: "space-between" }}>
|
||||
<View style={{ gap: 2 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("player.up_next")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{nextEpisode.Name}
|
||||
</Text>
|
||||
<Text style={{ color: "#a855f7", fontSize: 13 }}>
|
||||
{t("player.next_episode_in", { seconds: secondsRemaining })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
|
||||
<Pressable
|
||||
onPress={onPlayNow}
|
||||
accessibilityRole='button'
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#a855f7",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||
{t("player.play_now")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
accessibilityRole='button'
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#333",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||
{t("player.cancel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -1,62 +1,18 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Switch, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
|
||||
auto: "Automatic (recommended)",
|
||||
"force-hevc": "Force HEVC / H265",
|
||||
"force-h264": "Force H264",
|
||||
};
|
||||
|
||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const profileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: (
|
||||
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
|
||||
).map((mode) => ({
|
||||
type: "radio" as const,
|
||||
label: PROFILE_LABELS[mode],
|
||||
value: mode,
|
||||
selected: (settings.chromecastProfile ?? "auto") === mode,
|
||||
onPress: () => updateSettings({ chromecastProfile: mode }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings.chromecastProfile, updateSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
<ListItem
|
||||
title={"Profile"}
|
||||
subtitle={
|
||||
"Automatic picks codecs per device. Override only if needed."
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={profileOptions}
|
||||
title={"Chromecast profile"}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
<ListItem title={"Enable H265 for Chromecast"}>
|
||||
<Switch
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -98,48 +96,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
|
||||
// valid selection even if an out-of-range value was stored previously.
|
||||
const autoplayCountdown = Math.min(
|
||||
60,
|
||||
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
|
||||
);
|
||||
const castAutoplayCountdown = Math.min(
|
||||
60,
|
||||
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
|
||||
);
|
||||
|
||||
const autoplayCountdownOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||
type: "radio" as const,
|
||||
label: String(seconds),
|
||||
value: String(seconds),
|
||||
selected: seconds === autoplayCountdown,
|
||||
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[autoplayCountdown, updateSettings],
|
||||
);
|
||||
|
||||
const castAutoplayCountdownOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||
type: "radio" as const,
|
||||
label: String(seconds),
|
||||
value: String(seconds),
|
||||
selected: seconds === castAutoplayCountdown,
|
||||
onPress: () =>
|
||||
updateSettings({ castAutoplayCountdownSeconds: seconds }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[castAutoplayCountdown, updateSettings],
|
||||
);
|
||||
|
||||
const playbackSpeedOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -295,57 +251,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* Media Segment Skip Settings */}
|
||||
<ListItem
|
||||
title={t("home.settings.other.segment_skip_settings")}
|
||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
||||
onPress={() => router.push("/settings/segment-skip/page")}
|
||||
>
|
||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoplayCountdownOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={castAutoplayCountdownOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{castAutoplayCountdown}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
@@ -366,6 +271,3 @@ const AUTOPLAY_EPISODES_COUNT = (
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
|
||||
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
|
||||
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];
|
||||
|
||||
@@ -13,10 +13,13 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
// Show "0.54.1 (42)": the build number (CFBundleVersion / versionCode, auto-incremented
|
||||
// by EAS) uniquely identifies a build, so TestFlight/dev reports still pin the exact build.
|
||||
const appVersion = Application?.nativeApplicationVersion;
|
||||
const buildVersion = Application?.nativeBuildVersion;
|
||||
const version = appVersion
|
||||
? `${appVersion}${buildVersion ? ` (${buildVersion})` : ""}`
|
||||
: buildVersion || "N/A";
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
ChapterInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
@@ -37,14 +35,11 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
skipButtonText: string;
|
||||
showSkipCreditButton: boolean;
|
||||
skipCreditButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
api?: Api | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
handleControlsInteraction: () => void;
|
||||
@@ -80,9 +75,6 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Chapter props
|
||||
chapterPositions?: number[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -95,14 +87,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
skipButtonText,
|
||||
showSkipCreditButton,
|
||||
skipCreditButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
api,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
handleControlsInteraction,
|
||||
@@ -119,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
@@ -133,83 +121,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
);
|
||||
const hasChapters = chapterMarkerList.length > 1;
|
||||
|
||||
// Autoplay overlay: shown under the same condition the old countdown button used.
|
||||
const autoplayAllowed =
|
||||
settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||
|
||||
const showNextEpisodeCountdown =
|
||||
autoplayAllowed &&
|
||||
(!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000);
|
||||
|
||||
const [secondsRemaining, setSecondsRemaining] = useState(
|
||||
settings.autoplayCountdownSeconds,
|
||||
);
|
||||
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Keep a stable ref to the autoplay handler so the timer effect does not
|
||||
// restart when the handler identity changes.
|
||||
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
|
||||
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNextEpisodeCountdown || autoplayCancelled) {
|
||||
// Either the show-condition flipped off OR the user cancelled.
|
||||
// In both cases, stop the running timer immediately so autoplay
|
||||
// can't fire after Cancel was pressed.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
// Only reset cancellation + seconds when the show-condition itself
|
||||
// flipped off — a fresh credits/end-of-video window then starts a
|
||||
// brand-new countdown. If we got here because autoplayCancelled
|
||||
// just flipped true, keep it true so the countdown stays stopped.
|
||||
if (!showNextEpisodeCountdown) {
|
||||
setAutoplayCancelled(false);
|
||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSecondsRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
autoPlayHandlerRef.current();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
showNextEpisodeCountdown,
|
||||
autoplayCancelled,
|
||||
settings.autoplayCountdownSeconds,
|
||||
]);
|
||||
|
||||
const nextEpisodePosterUrl = useMemo(
|
||||
() =>
|
||||
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
|
||||
[api, nextItem],
|
||||
);
|
||||
|
||||
// Current chapter name for the always-visible header label (live playback).
|
||||
const currentChapterName = useMemo(
|
||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||
@@ -231,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
bottom:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? Math.max(insets.bottom - 17, 0)
|
||||
: 0,
|
||||
right: insets.right,
|
||||
left: insets.left,
|
||||
bottom: Math.max(insets.bottom - 17, 0),
|
||||
},
|
||||
]}
|
||||
className={"flex flex-col px-2"}
|
||||
@@ -273,21 +180,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText={skipButtonText}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -297,16 +193,34 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText={skipCreditButtonText}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
|
||||
<AutoplayCountdown
|
||||
nextEpisode={nextItem}
|
||||
posterUrl={nextEpisodePosterUrl}
|
||||
secondsRemaining={secondsRemaining}
|
||||
onPlayNow={handleNextEpisodeManual}
|
||||
onCancel={() => setAutoplayCancelled(true)}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center ml-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { FC } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
goToNextChapter,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -4,15 +4,7 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -24,17 +16,17 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -51,9 +43,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
// No-op function to avoid creating new references on every render
|
||||
const noop = () => {};
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
isPlaying: boolean;
|
||||
@@ -122,24 +111,6 @@ export const Controls: FC<Props> = ({
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
|
||||
const playingRef = useRef(isPlaying);
|
||||
useEffect(() => {
|
||||
playingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -248,7 +219,6 @@ export const Controls: FC<Props> = ({
|
||||
hasNextChapter,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
chapterPositions,
|
||||
} = useChapterNavigation({
|
||||
chapters: item.Chapters,
|
||||
progress,
|
||||
@@ -346,125 +316,27 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
downloadedFiles,
|
||||
api,
|
||||
downloadedFiles,
|
||||
);
|
||||
|
||||
// Convert milliseconds to seconds for segment comparison
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
|
||||
// Wrapper to convert segment skip from seconds to milliseconds
|
||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
// Cancel any pending play call to avoid race conditions
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
// Read latest isPlaying from ref to avoid stale closure
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
if (playingRef.current) {
|
||||
play();
|
||||
}
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments || [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments || [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments || [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments || [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Determine which segment button to show (priority order)
|
||||
// Commercial > Recap > Intro > Preview > Outro
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return { type: "Commercial", ...commercialSkipper };
|
||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
||||
if (previewSkipper.currentSegment)
|
||||
return { type: "Preview", ...previewSkipper };
|
||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
recapSkipper.currentSegment,
|
||||
introSkipper.currentSegment,
|
||||
previewSkipper.currentSegment,
|
||||
outroSkipper.currentSegment,
|
||||
commercialSkipper,
|
||||
recapSkipper,
|
||||
introSkipper,
|
||||
previewSkipper,
|
||||
outroSkipper,
|
||||
]);
|
||||
|
||||
// Legacy compatibility: map to old variable names
|
||||
const showSkipButton = !!(
|
||||
activeSegment &&
|
||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
||||
);
|
||||
const skipIntro = activeSegment?.skipSegment || noop;
|
||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||
const skipCredit = outroSkipper.skipSegment || noop;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
|
||||
// Get button text based on segment type using i18n
|
||||
const { t } = useTranslation();
|
||||
const skipButtonText = activeSegment
|
||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||
: t("player.skip_intro");
|
||||
const skipCreditButtonText = t("player.skip_outro");
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
maxMs,
|
||||
);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -493,7 +365,9 @@ export const Controls: FC<Props> = ({
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||
router.setParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
@@ -502,11 +376,17 @@ export const Controls: FC<Props> = ({
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
});
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
offline,
|
||||
],
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
@@ -691,14 +571,11 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
skipButtonText={skipButtonText}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipCreditButtonText={skipCreditButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
api={api}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
handleControlsInteraction={handleControlsInteraction}
|
||||
@@ -715,7 +592,6 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
chapterPositions={chapterPositions}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
paddingTop:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
paddingLeft:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
paddingRight:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -5,12 +5,11 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { orientation, lockOrientation } = useOrientation();
|
||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
|
||||
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||
onFinish?: () => void;
|
||||
onPress?: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
onFinish,
|
||||
onPress,
|
||||
show,
|
||||
...props
|
||||
}) => {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
progress.value = 0;
|
||||
progress.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 10000, // 10 seconds
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished && onFinish) {
|
||||
runOnJS(onFinish)();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||
return () => {
|
||||
cancelAnimation(progress);
|
||||
};
|
||||
}
|
||||
}, [show, onFinish]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${progress.value * 100}%`,
|
||||
backgroundColor: Colors.primary,
|
||||
};
|
||||
});
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
|
||||
{...props}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Animated.View style={animatedStyle} />
|
||||
<View className='px-3 py-3'>
|
||||
<Text numberOfLines={1} className='text-center font-bold'>
|
||||
{t("player.next_episode")}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextEpisodeCountDownButton;
|
||||
@@ -16,8 +16,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
|
||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
left: Math.max(insets.left, 48) + 20,
|
||||
}
|
||||
: {
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Chromecast Cast Test Matrix
|
||||
|
||||
Manual verification for the device-profile work. Run each row by casting the
|
||||
matching media from the app to a physical Chromecast and recording the result.
|
||||
|
||||
**Test device:** ___________________ (model name as reported by the app)
|
||||
**App build / commit:** ___________________
|
||||
**Date:** ___________________
|
||||
|
||||
## How to run
|
||||
|
||||
1. Pick a library item matching the row's codec / audio / container.
|
||||
2. Cast it. Note whether it direct-plays or transcodes (server logs show
|
||||
`Video is being transcoded` vs `Video is being direct played`).
|
||||
3. Record the load result: OK / 2100 / infinite-loading / other.
|
||||
|
||||
## Matrix
|
||||
|
||||
| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | |
|
||||
| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | |
|
||||
| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | |
|
||||
| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | |
|
||||
| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | |
|
||||
| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | |
|
||||
| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | |
|
||||
| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | |
|
||||
| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | |
|
||||
| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | |
|
||||
|
||||
## Outcome
|
||||
|
||||
- Highest video bitrate that loads reliably on the test device: ___________
|
||||
-> update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in
|
||||
`utils/casting/capabilities.ts` accordingly.
|
||||
- Confirmed cause of issue #1423 (<= 2 Mb/s): ___________
|
||||
- Confirmed cause of the 5.1 crash (#1085): ___________
|
||||
- Cases where downgrade-on-failure retry rescued playback: ___________
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* Cast autoplay watcher.
|
||||
*
|
||||
* Always-mounted hook: subscribes to the Chromecast `mediaStatus`, captures the
|
||||
* currently-playing episode while playback is active, and on either
|
||||
* (a) playback entering the Outro segment (when `skipOutro !== "auto"`), or
|
||||
* (b) `IDLE + FINISHED` (hard end of media),
|
||||
* starts a cancellable countdown via `castAutoplayAtom` and ultimately loads
|
||||
* the next episode on the cast.
|
||||
*
|
||||
* The countdown atom is driven here; the casting-player overlay reads it.
|
||||
* Cancellation (overlay's Cancel button) sets the atom to `null` externally;
|
||||
* the watcher reacts by clearing its interval and refusing to retrigger for
|
||||
* the same item.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
MediaPlayerIdleReason,
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
|
||||
/**
|
||||
* Cached next-episode resolution, keyed by the captured (seriesId, currentEpisodeId)
|
||||
* pair so the network calls are not repeated on every `mediaStatus` tick.
|
||||
*/
|
||||
interface NextEpisodeCache {
|
||||
seriesId: string;
|
||||
currentEpisodeId: string;
|
||||
nextEpisode: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export interface ShouldStartCountdownParams {
|
||||
playerState: MediaPlayerState | undefined;
|
||||
idleReason: MediaPlayerIdleReason | undefined;
|
||||
currentPositionMs: number;
|
||||
outroStartMs: number | null;
|
||||
outroEndMs: number | null;
|
||||
skipOutro: string;
|
||||
alreadyTriggered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision helper: should the countdown start *right now*?
|
||||
* Exported for testability.
|
||||
*/
|
||||
export const shouldStartCountdown = ({
|
||||
playerState,
|
||||
idleReason,
|
||||
currentPositionMs,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
skipOutro,
|
||||
alreadyTriggered,
|
||||
}: ShouldStartCountdownParams): boolean => {
|
||||
if (alreadyTriggered) return false;
|
||||
|
||||
// (b) hard end of media — fires regardless of segment availability.
|
||||
if (
|
||||
playerState === MediaPlayerState.IDLE &&
|
||||
idleReason === MediaPlayerIdleReason.FINISHED
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// (a) playback inside Outro segment, and Outro is not already auto-skipped.
|
||||
if (
|
||||
skipOutro !== "auto" &&
|
||||
outroStartMs != null &&
|
||||
outroEndMs != null &&
|
||||
currentPositionMs >= outroStartMs &&
|
||||
currentPositionMs < outroEndMs
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useCastAutoplay = (): void => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [autoplayState, setAutoplayState] = useAtom(castAutoplayAtom);
|
||||
|
||||
// Continuously captured currently-playing item (full BaseItemDto, fetched
|
||||
// from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays.
|
||||
const capturedItemRef = useRef<BaseItemDto | null>(null);
|
||||
const capturedItemIdRef = useRef<string | null>(null);
|
||||
// State mirror of the captured item id so downstream effects/hooks re-run
|
||||
// *after* the async getItem resolves — depending on `contentId` directly
|
||||
// would fire them before the ref is populated and they'd read stale data.
|
||||
const [capturedItemId, setCapturedItemId] = useState<string | null>(null);
|
||||
|
||||
// Cached next-episode resolution per (seriesId, currentEpisodeId).
|
||||
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(null);
|
||||
|
||||
// Last item id we triggered a countdown for. Reset when captured item changes
|
||||
// so the same finished episode does not retrigger.
|
||||
const triggeredForItemIdRef = useRef<string | null>(null);
|
||||
|
||||
// Countdown interval handle.
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Track whether the atom transitioned to null while a countdown is running —
|
||||
// that means the overlay cancelled, so we must not retrigger for this item.
|
||||
const autoplayStateRef = useRef(autoplayState);
|
||||
autoplayStateRef.current = autoplayState;
|
||||
|
||||
// Latest settings snapshot reachable from the interval / load callback
|
||||
// without re-creating the interval on every settings change.
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
const updateSettingsRef = useRef(updateSettings);
|
||||
updateSettingsRef.current = updateSettings;
|
||||
|
||||
const apiRef = useRef(api);
|
||||
apiRef.current = api;
|
||||
const userRef = useRef(user);
|
||||
userRef.current = user;
|
||||
const remoteMediaClientRef = useRef(remoteMediaClient);
|
||||
remoteMediaClientRef.current = remoteMediaClient;
|
||||
const castDeviceRef = useRef(castDevice);
|
||||
castDeviceRef.current = castDevice;
|
||||
|
||||
const contentId = mediaStatus?.mediaInfo?.contentId ?? null;
|
||||
|
||||
// --- 1. Capture the currently-playing item, full BaseItemDto. ---
|
||||
useEffect(() => {
|
||||
if (!contentId || !api || !user?.Id) {
|
||||
// No active content: clear all captured state so downstream effects /
|
||||
// useSegments stop using a stale previous-item id.
|
||||
capturedItemRef.current = null;
|
||||
capturedItemIdRef.current = null;
|
||||
setCapturedItemId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the captured id changed, reset the trigger guard immediately — the
|
||||
// user moved to another episode, and that new episode should be eligible.
|
||||
if (capturedItemIdRef.current !== contentId) {
|
||||
triggeredForItemIdRef.current = null;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem(
|
||||
{ itemId: contentId, userId: user.Id! },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (cancelled) return;
|
||||
capturedItemRef.current = res.data;
|
||||
capturedItemIdRef.current = contentId;
|
||||
// Publish the captured id as state *after* the ref is set, so the
|
||||
// next-episode-resolve effect (keyed on this state) sees a populated
|
||||
// ref by the time it runs.
|
||||
setCapturedItemId(contentId);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
// Non-fatal: keep whatever we last captured.
|
||||
console.error("[useCastAutoplay] Failed to fetch item:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [contentId, api, user?.Id]);
|
||||
|
||||
// --- 2. Resolve next episode (cached per series+episode). ---
|
||||
// This effect runs whenever the captured item id changes; the cache key
|
||||
// prevents refetching on every mediaStatus tick.
|
||||
useEffect(() => {
|
||||
const item = capturedItemRef.current;
|
||||
if (!item || !api || !user) return;
|
||||
if (item.Type !== "Episode") {
|
||||
nextEpisodeCacheRef.current = null;
|
||||
return;
|
||||
}
|
||||
const seriesId = item.SeriesId;
|
||||
const currentEpisodeId = item.Id;
|
||||
if (!seriesId || !currentEpisodeId) {
|
||||
nextEpisodeCacheRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = nextEpisodeCacheRef.current;
|
||||
if (
|
||||
cached &&
|
||||
cached.seriesId === seriesId &&
|
||||
cached.currentEpisodeId === currentEpisodeId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const episodes = await fetchSeriesEpisodes(api, user, seriesId);
|
||||
if (cancelled) return;
|
||||
nextEpisodeCacheRef.current = {
|
||||
seriesId,
|
||||
currentEpisodeId,
|
||||
nextEpisode: findNextEpisode(episodes, currentEpisodeId),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to resolve next episode:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Depend on the *state* mirror of the captured id rather than `contentId`
|
||||
// directly: `contentId` flips synchronously on the new episode, but
|
||||
// `capturedItemRef.current` is only populated after the async getItem
|
||||
// resolves. Keying on `capturedItemId` (set right after the ref write)
|
||||
// guarantees the ref points at the new item by the time we read it here.
|
||||
}, [capturedItemId, api, user]);
|
||||
|
||||
// --- 3. Media segments for the captured item (Outro). ---
|
||||
// Matches `useChromecastSegments`: cast playback is online, no downloaded
|
||||
// files context to thread through.
|
||||
const { data: segmentData } = useSegments(
|
||||
capturedItemId ?? "",
|
||||
false,
|
||||
undefined,
|
||||
api,
|
||||
);
|
||||
|
||||
const outroSegment = segmentData?.creditSegments?.[0] ?? null;
|
||||
const outroStartMs = outroSegment ? outroSegment.startTime * 1000 : null;
|
||||
const outroEndMs = outroSegment ? outroSegment.endTime * 1000 : null;
|
||||
|
||||
// --- 4. Trigger detection. ---
|
||||
useEffect(() => {
|
||||
// Master gate: setting must allow autoplay, and a countdown must not be
|
||||
// already running. The atom drives the countdown; an active atom means
|
||||
// we already triggered (possibly via overlay's Play now).
|
||||
if (!settings.autoPlayNextEpisode) return;
|
||||
if (autoplayState !== null) return;
|
||||
|
||||
const maxValue = settings.maxAutoPlayEpisodeCount.value;
|
||||
if (maxValue !== -1 && settings.autoPlayEpisodeCount >= maxValue) return;
|
||||
|
||||
const capturedItem = capturedItemRef.current;
|
||||
const capturedItemId = capturedItemIdRef.current;
|
||||
if (!capturedItem || !capturedItemId) return;
|
||||
if (capturedItem.Type !== "Episode") return;
|
||||
|
||||
const cached = nextEpisodeCacheRef.current;
|
||||
if (
|
||||
!cached ||
|
||||
cached.currentEpisodeId !== capturedItemId ||
|
||||
!cached.nextEpisode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextEpisode = cached.nextEpisode;
|
||||
|
||||
const currentPositionMs = (mediaStatus?.streamPosition ?? 0) * 1000;
|
||||
|
||||
const should = shouldStartCountdown({
|
||||
playerState: mediaStatus?.playerState as MediaPlayerState | undefined,
|
||||
idleReason: mediaStatus?.idleReason as MediaPlayerIdleReason | undefined,
|
||||
currentPositionMs,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
skipOutro: settings.skipOutro,
|
||||
alreadyTriggered: triggeredForItemIdRef.current === capturedItemId,
|
||||
});
|
||||
|
||||
if (!should) return;
|
||||
|
||||
triggeredForItemIdRef.current = capturedItemId;
|
||||
setAutoplayState({
|
||||
nextEpisode,
|
||||
secondsRemaining: settings.castAutoplayCountdownSeconds,
|
||||
});
|
||||
// The countdown interval is started by the effect below (reacts to the
|
||||
// atom transitioning to non-null), so this effect stays pure-decide.
|
||||
}, [
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.idleReason,
|
||||
mediaStatus?.streamPosition,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
settings.autoPlayNextEpisode,
|
||||
settings.autoPlayEpisodeCount,
|
||||
settings.maxAutoPlayEpisodeCount,
|
||||
settings.castAutoplayCountdownSeconds,
|
||||
settings.skipOutro,
|
||||
autoplayState,
|
||||
setAutoplayState,
|
||||
]);
|
||||
|
||||
// --- 5. Run countdown interval whenever atom is non-null. ---
|
||||
// Starting/stopping is driven by the atom value, so an external Cancel
|
||||
// (overlay) that sets the atom to null naturally tears the interval down.
|
||||
useEffect(() => {
|
||||
if (autoplayState === null) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only start an interval if one is not already running.
|
||||
if (intervalRef.current) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
// Read latest atom value from ref to decide what to do next.
|
||||
const current = autoplayStateRef.current;
|
||||
if (current === null) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = current.secondsRemaining - 1;
|
||||
if (next > 0) {
|
||||
setAutoplayState({ ...current, secondsRemaining: next });
|
||||
return;
|
||||
}
|
||||
|
||||
// Time's up — load the next episode and clear.
|
||||
// Snapshot what we need; clear the interval and atom synchronously to
|
||||
// avoid double-fire.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const episodeToLoad = current.nextEpisode;
|
||||
setAutoplayState(null);
|
||||
|
||||
const apiLocal = apiRef.current;
|
||||
const userLocal = userRef.current;
|
||||
const clientLocal = remoteMediaClientRef.current;
|
||||
const deviceLocal = castDeviceRef.current;
|
||||
const settingsLocal = settingsRef.current;
|
||||
|
||||
if (!apiLocal || !userLocal?.Id || !clientLocal || !episodeToLoad?.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror `useCastEpisodes.loadEpisode` exactly — same arguments,
|
||||
// same start-position derivation.
|
||||
(async () => {
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episodeToLoad.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client: clientLocal,
|
||||
device: deviceLocal,
|
||||
api: apiLocal,
|
||||
item: episodeToLoad,
|
||||
userId: userLocal.Id!,
|
||||
profileMode: settingsLocal.chromecastProfile,
|
||||
maxBitrateSetting: settingsLocal.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to load next episode:",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the freshest count at the moment of the write — the
|
||||
// overlay's "Play now" can reset this to 0 in parallel, and using
|
||||
// a snapshot taken before the await would clobber that reset.
|
||||
updateSettingsRef.current({
|
||||
autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1,
|
||||
});
|
||||
toast("Playing next episode");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to load next episode:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoplayState, setAutoplayState]);
|
||||
|
||||
// --- 6. Final unmount cleanup is covered by the interval effect's
|
||||
// return; nothing else to do here.
|
||||
};
|
||||
|
||||
export default useCastAutoplay;
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ImperativeRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface UseCastDismissGestureParams {
|
||||
router: ImperativeRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swipe-down-to-dismiss gesture cluster for the casting player modal.
|
||||
* Owns the `translateY`/`context` shared values, the pan gesture, the animated
|
||||
* style, and the `dismissModal` callback (also invoked by the header button).
|
||||
*/
|
||||
export function useCastDismissGesture({ router }: UseCastDismissGestureParams) {
|
||||
// Swipe down to dismiss gesture
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ y: 0 });
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
// Navigate immediately without animation to prevent crashes
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { y: translateY.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Only allow downward swipes from top of screen
|
||||
if (event.translationY > 0) {
|
||||
translateY.value = context.value.y + event.translationY;
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
// Dismiss if swiped down more than 150px or fast swipe
|
||||
if (event.translationY > 150 || event.velocityY > 600) {
|
||||
// Animate down and dismiss
|
||||
translateY.value = withSpring(
|
||||
1000,
|
||||
{
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
},
|
||||
() => {
|
||||
runOnJS(dismissModal)();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Spring back to position
|
||||
translateY.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return { panGesture, animatedStyle, dismissModal };
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Device, RemoteMediaClient } from "react-native-google-cast";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||
|
||||
interface UseCastEpisodesParams {
|
||||
api: Api | null;
|
||||
user: UserDto | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
castDevice: Device | null;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
interface UseCastEpisodesResult {
|
||||
episodes: BaseItemDto[];
|
||||
nextEpisode: BaseItemDto | null;
|
||||
seasonData: BaseItemDto | null;
|
||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||
/**
|
||||
* Id of the episode currently being loaded onto the cast device, or null
|
||||
* when no load is pending. The cast `customData` (and thus `currentItem`)
|
||||
* lags behind the load, so consumers use this to detect the stale window
|
||||
* between a `loadEpisode` call and the cast reporting the new episode.
|
||||
*/
|
||||
loadingEpisodeId: string | null;
|
||||
}
|
||||
|
||||
export function useCastEpisodes({
|
||||
api,
|
||||
user,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings,
|
||||
}: UseCastEpisodesParams): UseCastEpisodesResult {
|
||||
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
||||
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
||||
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
||||
// Target episode id while a load is in flight; cleared once it resolves.
|
||||
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
|
||||
|
||||
// Load a different episode on the Chromecast
|
||||
const loadEpisode = useCallback(
|
||||
async (episode: BaseItemDto) => {
|
||||
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
|
||||
|
||||
setLoadingEpisodeId(episode.Id);
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: episode,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load episode:",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to load episode:", error);
|
||||
} finally {
|
||||
// Clear regardless of outcome: on success `currentItem` catches up via
|
||||
// customData; on failure the stale guard must not stay stuck.
|
||||
setLoadingEpisodeId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
],
|
||||
);
|
||||
|
||||
// Fetch season data for season poster
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentItem?.Type !== "Episode" ||
|
||||
!currentItem.SeasonId ||
|
||||
!api ||
|
||||
!user?.Id
|
||||
)
|
||||
return;
|
||||
|
||||
const fetchSeasonData = async () => {
|
||||
try {
|
||||
const userLibraryApi = getUserLibraryApi(api);
|
||||
const response = await userLibraryApi.getItem({
|
||||
itemId: currentItem.SeasonId!,
|
||||
userId: user.Id!,
|
||||
});
|
||||
setSeasonData(response.data);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to fetch season data:", error);
|
||||
setSeasonData(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSeasonData();
|
||||
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
|
||||
|
||||
// Fetch episodes for TV shows
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentItem?.Type !== "Episode" ||
|
||||
!currentItem.SeriesId ||
|
||||
!api ||
|
||||
!user
|
||||
)
|
||||
return;
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
// Fetch ALL episodes from ALL seasons (no season filter).
|
||||
const episodeList = await fetchSeriesEpisodes(
|
||||
api,
|
||||
user,
|
||||
currentItem.SeriesId!,
|
||||
);
|
||||
setEpisodes(episodeList);
|
||||
setNextEpisode(findNextEpisode(episodeList, currentItem.Id));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch episodes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEpisodes();
|
||||
}, [
|
||||
currentItem?.Type,
|
||||
currentItem?.SeriesId,
|
||||
currentItem?.SeasonId,
|
||||
currentItem?.Id,
|
||||
api,
|
||||
user,
|
||||
]);
|
||||
|
||||
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MediaStatus } from "react-native-google-cast";
|
||||
|
||||
interface UseCastPlayerItemParams {
|
||||
api: Api | null;
|
||||
user: UserDto | null;
|
||||
mediaStatus: MediaStatus | null;
|
||||
}
|
||||
|
||||
interface UseCastPlayerItemResult {
|
||||
fetchedItem: BaseItemDto | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export function useCastPlayerItem({
|
||||
api,
|
||||
user,
|
||||
mediaStatus,
|
||||
}: UseCastPlayerItemParams): UseCastPlayerItemResult {
|
||||
// Fetch full item data from Jellyfin by ID
|
||||
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchItemData = async () => {
|
||||
const itemId = mediaStatus?.mediaInfo?.contentId;
|
||||
if (!itemId || !api || !user?.Id) return;
|
||||
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem(
|
||||
{ itemId, userId: user.Id },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!controller.signal.aborted) {
|
||||
setFetchedItem(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
console.error("[Casting Player] Failed to fetch item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItemData();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
||||
|
||||
// Extract item from customData, or use fetched item, or create a minimal fallback
|
||||
const currentItem = useMemo(() => {
|
||||
// Priority 1: Use fetched item from API (most reliable)
|
||||
if (fetchedItem) {
|
||||
return fetchedItem;
|
||||
}
|
||||
|
||||
// Priority 2: Try customData from mediaStatus
|
||||
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
||||
if (
|
||||
customData?.Type &&
|
||||
(customData.ImageTags || customData.MediaSources || customData.Id)
|
||||
) {
|
||||
// Use customData if it has a real Type AND meaningful metadata
|
||||
// (rules out placeholder objects that lack image tags, media sources, or an ID)
|
||||
return customData;
|
||||
}
|
||||
|
||||
// Priority 3: Create minimal fallback while loading
|
||||
if (mediaStatus?.mediaInfo) {
|
||||
const { contentId, metadata } = mediaStatus.mediaInfo;
|
||||
// Derive type from metadata if available, otherwise omit to avoid
|
||||
// misrepresenting episodes as movies
|
||||
let metadataType: string | undefined;
|
||||
if (metadata?.type === "movie") {
|
||||
metadataType = "Movie";
|
||||
} else if (metadata?.type === "tvShow") {
|
||||
metadataType = "Episode";
|
||||
}
|
||||
return {
|
||||
Id: contentId,
|
||||
Name: metadata?.title || "Unknown",
|
||||
...(metadataType ? { Type: metadataType } : {}),
|
||||
ServerId: "",
|
||||
} as BaseItemDto;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [fetchedItem, mediaStatus?.mediaInfo]);
|
||||
|
||||
return { fetchedItem, currentItem };
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
|
||||
interface TrickplayTime {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface UseCastPlayerProgressParams {
|
||||
/** Raw Chromecast media status, or null when no session. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Full item fetched from Jellyfin, used to derive trickplay data. */
|
||||
fetchedItem: BaseItemDto | null;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface UseCastPlayerProgressResult {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: RefObject<boolean>;
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: TrickplayTime;
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: TrickplayTime) => void;
|
||||
/** Current playback progress, in seconds (live-updating). */
|
||||
progress: number;
|
||||
/** Last stable playback position (seconds), for resuming across reloads. */
|
||||
resumePositionRef: RefObject<number>;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress/slider/trickplay cluster for the casting player.
|
||||
* Owns the slider shared values, scrub state, live-progress interpolation,
|
||||
* resume-position tracking, and trickplay preview.
|
||||
*/
|
||||
export function useCastPlayerProgress({
|
||||
mediaStatus,
|
||||
fetchedItem,
|
||||
duration,
|
||||
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
|
||||
// Shared values for progress slider (must be initialized before any early returns)
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Trickplay time display
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
const lastSyncPositionRef = useRef(0);
|
||||
const lastSyncTimestampRef = useRef(Date.now());
|
||||
|
||||
// Last stable playback position (seconds), for resuming across reloads.
|
||||
const resumePositionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync refs whenever mediaStatus provides a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||
lastSyncTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update every second when playing, deriving from last sync point
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||
mediaStatus?.streamPosition !== undefined
|
||||
) {
|
||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||
|
||||
// Track the last stable position so a reload mid-switch resumes correctly.
|
||||
useEffect(() => {
|
||||
const pos = mediaStatus?.streamPosition ?? 0;
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
||||
resumePositionRef.current = pos;
|
||||
}
|
||||
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const progress = liveProgress; // Use live-updating progress
|
||||
|
||||
// Trickplay for seeking preview - use fetched item with full data
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
fetchedItem ?? null,
|
||||
);
|
||||
|
||||
// Update slider max when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Update slider progress when not scrubbing
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress > 0) {
|
||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
return {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Source of truth for the active cast track / quality / version selection.
|
||||
*
|
||||
* Truth = the CastSelection echoed back in the cast media customData. A local
|
||||
* `pending` selection is shown optimistically while a reload re-transcodes, then
|
||||
* cleared once the cast reports it (reconciled) or the reload fails.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { MediaStatus } from "react-native-google-cast";
|
||||
import { resolveSelection, selectionsEqual } from "@/utils/casting/selection";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
|
||||
interface UseCastSelectionParams {
|
||||
currentItem: BaseItemDto | null;
|
||||
mediaStatus: MediaStatus | null | undefined;
|
||||
/** Reload the cast stream with the given selection. Resolves true on success. */
|
||||
reload: (selection: CastSelection) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface UseCastSelectionResult {
|
||||
/** Effective selection: optimistic pending, else cast truth, else default. */
|
||||
currentSelection: CastSelection | null;
|
||||
/** Merge a partial selection, show it optimistically, and reload the stream. */
|
||||
applySelection: (partial: Partial<CastSelection>) => void;
|
||||
}
|
||||
|
||||
export const useCastSelection = ({
|
||||
currentItem,
|
||||
mediaStatus,
|
||||
reload,
|
||||
}: UseCastSelectionParams): UseCastSelectionResult => {
|
||||
const [pending, setPending] = useState<CastSelection | null>(null);
|
||||
|
||||
// Truth: the selection the cast reports as loaded, via customData.
|
||||
const truth =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { selection?: CastSelection }
|
||||
| undefined
|
||||
)?.selection ?? null;
|
||||
|
||||
const currentSelection: CastSelection | null =
|
||||
pending ??
|
||||
truth ??
|
||||
(currentItem ? resolveSelection(currentItem, {}) : null);
|
||||
|
||||
// A new media item invalidates any pending selection from the previous one.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only
|
||||
useEffect(() => {
|
||||
setPending(null);
|
||||
}, [currentItem?.Id]);
|
||||
|
||||
// Reconcile: once the cast reports the pending selection as loaded, clear it.
|
||||
useEffect(() => {
|
||||
if (pending && truth && selectionsEqual(pending, truth)) {
|
||||
setPending(null);
|
||||
}
|
||||
}, [pending, truth]);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(partial: Partial<CastSelection>) => {
|
||||
if (!currentSelection) return;
|
||||
const next: CastSelection = { ...currentSelection, ...partial };
|
||||
setPending(next);
|
||||
reload(next).then((ok) => {
|
||||
if (!ok) setPending(null);
|
||||
});
|
||||
},
|
||||
[currentSelection, reload],
|
||||
);
|
||||
|
||||
return { currentSelection, applySelection };
|
||||
};
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Hook
|
||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
||||
* Architecture allows for future protocol integrations
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
||||
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
||||
|
||||
/**
|
||||
* Unified hook for managing casting
|
||||
* Extensible architecture supporting multiple protocols
|
||||
*/
|
||||
export const useCasting = (item: BaseItemDto | null) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Chromecast hooks
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
// Local state
|
||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
||||
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
||||
|
||||
// Helper to update both state and ref
|
||||
const updateState = useCallback(
|
||||
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
stateRef.current = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
|
||||
const playSessionId =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { playSessionId?: string }
|
||||
| undefined
|
||||
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
|
||||
|
||||
const playMethod =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { playMethod?: "Transcode" | "DirectPlay" }
|
||||
| undefined
|
||||
)?.playMethod ?? "Transcode";
|
||||
|
||||
// Detect which protocol is active - use CastState for reliable detection
|
||||
const chromecastConnected = castState === CastState.CONNECTED;
|
||||
// Future: Add detection for other protocols here
|
||||
|
||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
||||
? "chromecast"
|
||||
: null;
|
||||
|
||||
const isConnected = chromecastConnected;
|
||||
|
||||
// Update current device
|
||||
useEffect(() => {
|
||||
if (chromecastConnected && castDevice) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
protocol: "chromecast",
|
||||
currentDevice: {
|
||||
id: castDevice.deviceId,
|
||||
name: castDevice.friendlyName || castDevice.deviceId,
|
||||
protocol: "chromecast",
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
protocol: null,
|
||||
currentDevice: null,
|
||||
}));
|
||||
}
|
||||
// Future: Add device detection for other protocols
|
||||
}, [chromecastConnected, castDevice]);
|
||||
|
||||
// Chromecast: Update playback state
|
||||
useEffect(() => {
|
||||
if (activeProtocol === "chromecast" && mediaStatus) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: mediaStatus.playerState === "playing",
|
||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
||||
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
||||
isBuffering: mediaStatus.playerState === "buffering",
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus, activeProtocol, updateState]);
|
||||
|
||||
// Chromecast: Sync volume from mediaStatus
|
||||
useEffect(() => {
|
||||
if (activeProtocol !== "chromecast") return;
|
||||
|
||||
// Sync from mediaStatus when available
|
||||
if (mediaStatus?.volume !== undefined) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
volume: mediaStatus.volume,
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
||||
|
||||
// Progress reporting to Jellyfin (matches native player behavior)
|
||||
// Uses stateRef to read current progress/volume without adding them as deps
|
||||
useEffect(() => {
|
||||
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
||||
|
||||
const playStateApi = getPlaystateApi(api);
|
||||
|
||||
// Report playback start when media begins (only once per item)
|
||||
// Don't require progress > 0 — playback can legitimately start at position 0
|
||||
const currentState = stateRef.current;
|
||||
const isPlaybackActive =
|
||||
currentState.isPlaying ||
|
||||
mediaStatus?.playerState === "playing" ||
|
||||
currentState.progress > 0;
|
||||
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
|
||||
// Set synchronously before async call to prevent race condition duplicates
|
||||
hasReportedStartRef.current = item.Id || null;
|
||||
|
||||
playStateApi
|
||||
.reportPlaybackStart({
|
||||
playbackStartInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: Math.floor(currentState.progress * 10000),
|
||||
PlayMethod: playMethod,
|
||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
||||
IsMuted: currentState.volume === 0,
|
||||
PlaySessionId: playSessionId,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
// Revert on failure so it can be retried
|
||||
hasReportedStartRef.current = null;
|
||||
console.error("[useCasting] Failed to report playback start:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const reportProgress = () => {
|
||||
const s = stateRef.current;
|
||||
// Don't report if no meaningful progress or if buffering
|
||||
if (s.progress <= 0 || s.isBuffering) return;
|
||||
|
||||
const progressMs = Math.floor(s.progress);
|
||||
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
||||
const progressSeconds = Math.floor(progressMs / 1000);
|
||||
|
||||
// When paused, always report to keep server in sync
|
||||
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
||||
if (
|
||||
s.isPlaying &&
|
||||
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedProgressRef.current = progressSeconds;
|
||||
|
||||
playStateApi
|
||||
.reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: progressTicks,
|
||||
IsPaused: !s.isPlaying,
|
||||
PlayMethod: playMethod,
|
||||
VolumeLevel: Math.floor(s.volume * 100),
|
||||
IsMuted: s.volume === 0,
|
||||
PlaySessionId: playSessionId,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useCasting] Failed to report progress:", error);
|
||||
});
|
||||
};
|
||||
|
||||
// Report progress on a fixed interval, reading latest state from ref
|
||||
const interval = setInterval(reportProgress, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
api,
|
||||
item?.Id,
|
||||
user?.Id,
|
||||
isConnected,
|
||||
activeProtocol,
|
||||
playSessionId,
|
||||
playMethod,
|
||||
]);
|
||||
|
||||
// Play/Pause controls
|
||||
const play = useCallback(async () => {
|
||||
if (activeProtocol === "chromecast") {
|
||||
// Check if there's an active media session
|
||||
if (!client || !mediaStatus?.mediaInfo) {
|
||||
console.warn(
|
||||
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.play();
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error playing:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Future: Add play control for other protocols
|
||||
}, [client, mediaStatus, activeProtocol]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (activeProtocol === "chromecast") {
|
||||
try {
|
||||
await client?.pause();
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error pausing:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Future: Add pause control for other protocols
|
||||
}, [client, activeProtocol]);
|
||||
|
||||
const togglePlayPause = useCallback(async () => {
|
||||
if (state.isPlaying) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}, [state.isPlaying, play, pause]);
|
||||
|
||||
// Seek controls
|
||||
const seek = useCallback(
|
||||
async (positionMs: number) => {
|
||||
// Validate position
|
||||
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
||||
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const positionSeconds = positionMs / 1000;
|
||||
|
||||
// Additional validation for Chromecast
|
||||
if (activeProtocol === "chromecast") {
|
||||
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
||||
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
|
||||
const durationSeconds = state.duration / 1000;
|
||||
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
|
||||
console.warn(
|
||||
"[useCasting] Seek position exceeds duration, clamping:",
|
||||
positionSeconds,
|
||||
"->",
|
||||
durationSeconds,
|
||||
);
|
||||
await client?.seek({ position: durationSeconds });
|
||||
return;
|
||||
}
|
||||
await client?.seek({ position: positionSeconds });
|
||||
}
|
||||
// Future: Add seek control for other protocols
|
||||
},
|
||||
[client, activeProtocol, state.duration],
|
||||
);
|
||||
|
||||
const skipForward = useCallback(
|
||||
async (seconds = 10) => {
|
||||
const newPosition = state.progress + seconds * 1000;
|
||||
await seek(Math.min(newPosition, state.duration));
|
||||
},
|
||||
[state.progress, state.duration, seek],
|
||||
);
|
||||
|
||||
const skipBackward = useCallback(
|
||||
async (seconds = 10) => {
|
||||
const newPosition = state.progress - seconds * 1000;
|
||||
await seek(Math.max(newPosition, 0));
|
||||
},
|
||||
[state.progress, seek],
|
||||
);
|
||||
|
||||
// Stop and disconnect
|
||||
const stop = useCallback(
|
||||
async (onStopComplete?: () => void) => {
|
||||
try {
|
||||
if (activeProtocol === "chromecast") {
|
||||
await client?.stop();
|
||||
}
|
||||
// Future: Add stop control for other protocols
|
||||
|
||||
// Report stop to Jellyfin
|
||||
if (api && item?.Id && user?.Id) {
|
||||
const playStateApi = getPlaystateApi(api);
|
||||
await playStateApi.reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: stateRef.current.progress * 10000,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error during stop:", error);
|
||||
} finally {
|
||||
hasReportedStartRef.current = null;
|
||||
setState(DEFAULT_CAST_STATE);
|
||||
stateRef.current = DEFAULT_CAST_STATE;
|
||||
|
||||
// Call callback after stop completes (e.g., to navigate away)
|
||||
if (onStopComplete) {
|
||||
onStopComplete();
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, api, item?.Id, user?.Id, activeProtocol],
|
||||
);
|
||||
|
||||
// Volume control (debounced to reduce API calls)
|
||||
const setVolume = useCallback(
|
||||
(volume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
// Update UI immediately
|
||||
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||
|
||||
// Debounce API call
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
||||
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
||||
// Physical volume buttons are handled automatically by the framework
|
||||
await client.setStreamVolume(clampedVolume).catch(() => {
|
||||
// Ignore errors - session might have ended
|
||||
});
|
||||
}
|
||||
// Future: Add volume control for other protocols
|
||||
}, 300);
|
||||
},
|
||||
[client, activeProtocol, isConnected],
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isConnected,
|
||||
protocol: activeProtocol,
|
||||
isPlaying: state.isPlaying,
|
||||
isBuffering: state.isBuffering,
|
||||
currentItem: item,
|
||||
currentDevice: state.currentDevice,
|
||||
progress: state.progress,
|
||||
duration: state.duration,
|
||||
volume: state.volume,
|
||||
|
||||
// Availability - derived from actual cast state
|
||||
isChromecastAvailable:
|
||||
castState === CastState.CONNECTED ||
|
||||
castState === CastState.CONNECTING ||
|
||||
castState === CastState.NOT_CONNECTED,
|
||||
|
||||
// Raw clients (for advanced operations)
|
||||
remoteMediaClient: client,
|
||||
|
||||
// Controls
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
stop,
|
||||
setVolume,
|
||||
};
|
||||
};
|
||||
18
hooks/useControlsSafeAreaInsets.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type EdgeInsets,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/**
|
||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||
* returns zero insets so controls can sit flush against the screen edges.
|
||||
*/
|
||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||
* resolves to an empty payload under RN's New Architecture.
|
||||
*/
|
||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let blob: Blob;
|
||||
const tmpFile = new File(
|
||||
Paths.cache,
|
||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||
);
|
||||
try {
|
||||
// Fetch the data from the URL
|
||||
const response = await fetch(url);
|
||||
blob = await response.blob();
|
||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||
idempotent: true,
|
||||
});
|
||||
return await downloaded.base64();
|
||||
} catch (error) {
|
||||
console.warn("Error fetching image:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (tmpFile.exists) tmpFile.delete();
|
||||
}
|
||||
|
||||
// Create a FileReader instance
|
||||
const reader = new FileReader();
|
||||
|
||||
// Convert blob to base64
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Extract the base64 string (remove the data URL prefix)
|
||||
const base64 = reader.result.split(",")[1];
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveImage = useCallback(
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Dispatches Jellyfin remote-control WebSocket messages to the active
|
||||
* PlaybackController. DisplayMessage is shown as an in-app toast and needs no
|
||||
* controller.
|
||||
*/
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
|
||||
import {
|
||||
mapRemoteCommand,
|
||||
type RemoteWsMessage,
|
||||
} from "@/utils/playback/remoteCommands";
|
||||
|
||||
/** Handle one remote-control message (call it whenever a new WS message arrives). */
|
||||
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
|
||||
const controller = useAtomValue(activePlaybackControllerAtom);
|
||||
const handledRef = useRef<RemoteWsMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage || lastMessage === handledRef.current) return;
|
||||
handledRef.current = lastMessage;
|
||||
const action = mapRemoteCommand(lastMessage);
|
||||
if (!action) return;
|
||||
|
||||
if (action.kind === "displayMessage") {
|
||||
toast(action.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controller) return;
|
||||
|
||||
switch (action.kind) {
|
||||
case "playPause":
|
||||
controller.playPause();
|
||||
break;
|
||||
case "pause":
|
||||
controller.pause();
|
||||
break;
|
||||
case "unpause":
|
||||
controller.unpause();
|
||||
break;
|
||||
case "stop":
|
||||
controller.stop();
|
||||
break;
|
||||
case "seek":
|
||||
controller.seek(action.positionMs);
|
||||
break;
|
||||
case "next":
|
||||
controller.next();
|
||||
break;
|
||||
case "previous":
|
||||
controller.previous();
|
||||
break;
|
||||
case "setVolume":
|
||||
controller.setVolume(action.level);
|
||||
break;
|
||||
case "toggleMute":
|
||||
controller.toggleMute();
|
||||
break;
|
||||
}
|
||||
}, [lastMessage, controller]);
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
|
||||
interface UseSegmentSkipperProps {
|
||||
segments: MediaTimeSegment[];
|
||||
segmentType: SegmentType;
|
||||
currentTime: number;
|
||||
totalDuration?: number;
|
||||
seek: (time: number) => void;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
interface UseSegmentSkipperReturn {
|
||||
currentSegment: MediaTimeSegment | null;
|
||||
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||
*/
|
||||
export const useSegmentSkipper = ({
|
||||
segments,
|
||||
segmentType,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
seek,
|
||||
isPaused,
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||
|
||||
// Get skip mode based on segment type
|
||||
const skipMode = (() => {
|
||||
switch (segmentType) {
|
||||
case "Intro":
|
||||
return settings.skipIntro;
|
||||
case "Outro":
|
||||
return settings.skipOutro;
|
||||
case "Recap":
|
||||
return settings.skipRecap;
|
||||
case "Commercial":
|
||||
return settings.skipCommercial;
|
||||
case "Preview":
|
||||
return settings.skipPreview;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
})();
|
||||
|
||||
// Find current segment
|
||||
const currentSegment =
|
||||
segments.find(
|
||||
(segment) =>
|
||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
||||
) || null;
|
||||
|
||||
// Skip function with optional haptic feedback
|
||||
const skipSegment = useCallback(
|
||||
(notifyOrUseHaptics = true) => {
|
||||
if (!currentSegment || skipMode === "none") return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (
|
||||
segmentType === "Outro" &&
|
||||
totalDuration != null &&
|
||||
Number.isFinite(totalDuration)
|
||||
) {
|
||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||
seek(seekTime);
|
||||
} else {
|
||||
seek(currentSegment.endTime);
|
||||
}
|
||||
|
||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
||||
if (notifyOrUseHaptics) {
|
||||
haptic();
|
||||
}
|
||||
},
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
||||
);
|
||||
// Auto-skip logic when mode is 'auto'
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track segment identity to avoid re-triggering on pause/unpause
|
||||
const segmentId = currentSegment
|
||||
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
||||
: null;
|
||||
|
||||
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
||||
autoSkipTriggeredRef.current = segmentId;
|
||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||
}
|
||||
|
||||
if (!currentSegment) {
|
||||
autoSkipTriggeredRef.current = null;
|
||||
}
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
// Return null segment if skip mode is 'none'
|
||||
return {
|
||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||
skipSegment,
|
||||
};
|
||||
};
|
||||
@@ -17,24 +17,20 @@ interface TrickplayUrl {
|
||||
}
|
||||
|
||||
/** Hook to handle trickplay logic for a given item. */
|
||||
export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200;
|
||||
const isOffline = useGlobalSearchParams().offline === "true";
|
||||
const trickplayInfo = useMemo(
|
||||
() => (item ? getTrickplayInfo(item) : null),
|
||||
[item],
|
||||
);
|
||||
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||
|
||||
/** Generates the trickplay URL for the given item and sheet index.
|
||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||
const getTrickplayUrl = useCallback(
|
||||
(item: BaseItemDto, sheetIndex: number) => {
|
||||
if (!item.Id) return null;
|
||||
// If we are offline, we can use the downloaded item's trickplay data path
|
||||
const downloadedItem = getDownloadedItemById(item.Id);
|
||||
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||
}
|
||||
@@ -49,7 +45,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!trickplayInfo ||
|
||||
!item?.Id ||
|
||||
!item.Id ||
|
||||
now - lastCalculationTime.current < throttleDelay
|
||||
)
|
||||
return;
|
||||
@@ -66,7 +62,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
|
||||
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
||||
const prefetchAllTrickplayImages = useCallback(async () => {
|
||||
if (!trickplayInfo || !item?.Id) return;
|
||||
if (!trickplayInfo || !item.Id) return;
|
||||
const maxConcurrent = 4;
|
||||
const total = trickplayInfo.totalImageSheets;
|
||||
const urls: string[] = [];
|
||||
|
||||
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView {
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
let onPictureInPictureChange = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
@@ -81,7 +82,6 @@ class MpvPlayerView: ExpoView {
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
configureAudioSession()
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -141,21 +141,26 @@ class MpvPlayerView: ExpoView {
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func configureAudioSession() {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback,
|
||||
policy: .longFormAudio,
|
||||
options: []
|
||||
)
|
||||
try audioSession.setActive(true)
|
||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
}
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||
private func tearDownAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||
@@ -270,6 +275,7 @@ class MpvPlayerView: ExpoView {
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
configureAudioSession()
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
@@ -440,6 +446,7 @@ class MpvPlayerView: ExpoView {
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
tearDownAudioSession()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@@ -519,9 +526,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||
nowPlayingManager.activateAudioSession()
|
||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
}
|
||||
}
|
||||
@@ -633,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -651,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
|
||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||
let cachedDb: DownloadsDatabase | null = null;
|
||||
let cacheVersion = 0;
|
||||
|
||||
// Performance optimization: Cache the flattened items array
|
||||
let cachedItems: DownloadedItem[] | null = null;
|
||||
let itemsCacheVersion = -1;
|
||||
|
||||
// Performance optimization: Index for O(1) item lookups by ID
|
||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||
let indexCacheVersion = -1;
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
// Return cached database if available
|
||||
if (cachedDb !== null) {
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
// Parse from storage and cache the result
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||
return cachedDb;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
|
||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||
cachedDb = emptyDb;
|
||||
return emptyDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
// Update the cache with the new database
|
||||
cachedDb = db;
|
||||
// Invalidate derived caches (items array and index)
|
||||
cachedItems = null;
|
||||
itemIndex = null;
|
||||
cacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
// Return cached items if available and up-to-date
|
||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
// Build the items array from the database
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cachedItems = items;
|
||||
itemsCacheVersion = cacheVersion;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* Build or refresh the item index for O(1) lookups
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
function ensureItemIndex(): void {
|
||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
||||
return; // Index is up-to-date
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
// Build new index from all items
|
||||
itemIndex = new Map<string, DownloadedItem>();
|
||||
const items = getAllDownloadedItems();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.item.Id) {
|
||||
itemIndex.set(item.item.Id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
indexCacheVersion = cacheVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
ensureItemIndex();
|
||||
return itemIndex!.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
// saveDownloadsDatabase already invalidates caches
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The Jellyfin item DTO. */
|
||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
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. */
|
||||
userData: UserData;
|
||||
}
|
||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||
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 */
|
||||
audioStreamIndex?: number;
|
||||
/** The subtitle stream index selected for this download */
|
||||
|
||||
@@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(storedUser);
|
||||
}
|
||||
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
// Dismiss splash screen with cached data immediately,
|
||||
// fetch fresh user data in the background
|
||||
setInitialLoaded(true);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Background fetch failed — app already rendered with cached data
|
||||
console.warn("Background user fetch failed, using cached data:", e);
|
||||
}
|
||||
} else {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,10 +28,6 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
// Conditionally import TrackPlayer only on non-TV platforms
|
||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||
@@ -1625,43 +1621,6 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
settings?.audioLookaheadCount,
|
||||
]);
|
||||
|
||||
// App-wide remote-control surface: wraps the existing music controls so
|
||||
// remote commands can target whatever player is currently active.
|
||||
const isMusicActive = state.currentTrack !== null;
|
||||
|
||||
const playbackController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
playPause: () => {
|
||||
togglePlayPause();
|
||||
},
|
||||
pause: () => {
|
||||
pause();
|
||||
},
|
||||
unpause: () => {
|
||||
resume();
|
||||
},
|
||||
stop: () => {
|
||||
stop();
|
||||
},
|
||||
// TrackPlayer works in seconds; the controller contract is milliseconds.
|
||||
seek: (positionMs: number) => {
|
||||
seek(positionMs / 1000);
|
||||
},
|
||||
next: () => {
|
||||
next();
|
||||
},
|
||||
previous: () => {
|
||||
previous();
|
||||
},
|
||||
// The music player exposes no volume API — keep these as no-ops.
|
||||
setVolume: () => {},
|
||||
toggleMute: () => {},
|
||||
}),
|
||||
[togglePlayPause, pause, resume, stop, seek, next, previous],
|
||||
);
|
||||
|
||||
useRegisterPlaybackController(playbackController, isMusicActive);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useRemoteControl } from "@/hooks/useRemoteControl";
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
|
||||
@@ -55,8 +54,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
// Route Jellyfin remote-control messages to the active player.
|
||||
useRemoteControl(lastMessage);
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const deviceId = useMemo(() => {
|
||||
@@ -222,14 +219,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
IconUrl:
|
||||
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||
PlayableMediaTypes: ["Audio", "Video"],
|
||||
SupportedCommands: [
|
||||
"Play",
|
||||
"DisplayMessage",
|
||||
"SetVolume",
|
||||
"ToggleMute",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
],
|
||||
SupportedCommands: ["Play"],
|
||||
SupportsMediaControl: true,
|
||||
SupportsPersistentIdentifier: true,
|
||||
},
|
||||
|
||||
@@ -27,112 +27,6 @@
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"player": {
|
||||
"skip_intro": "Skip Intro",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"skip_outro": "Skip Outro",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_preview": "Skip Preview",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"up_next": "Up next",
|
||||
"next_episode_in": "Next episode in {{seconds}}s",
|
||||
"play_now": "Play now",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"casting_player": {
|
||||
"buffering": "Buffering...",
|
||||
"changing_audio": "Changing audio...",
|
||||
"changing_subtitles": "Changing subtitles...",
|
||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
||||
"connecting": "Connecting to Chromecast...",
|
||||
"unknown_device": "Unknown Device",
|
||||
"ending_at": "Ending at {{time}}",
|
||||
"unknown": "Unknown",
|
||||
"connected": "Connected",
|
||||
"volume": "Volume",
|
||||
"muted": "Muted",
|
||||
"disconnect": "Disconnect",
|
||||
"stop_casting": "Stop Casting",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"chromecast": "Chromecast",
|
||||
"device_name": "Device Name",
|
||||
"playback_settings": "Playback Settings",
|
||||
"version": "Version",
|
||||
"stop": "Stop",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"none": "None",
|
||||
"playback_speed": "Playback Speed",
|
||||
"normal": "Normal",
|
||||
"episodes": "Episodes",
|
||||
"season": "Season {{number}}",
|
||||
"minutes_short": "min",
|
||||
"episode_label": "Episode {{number}}",
|
||||
"forced": "Forced",
|
||||
"device": "Device",
|
||||
"cancel": "Cancel",
|
||||
"connection_quality": {
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"fair": "Fair",
|
||||
"poor": "Poor",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"error_title": "Chromecast Error",
|
||||
"error_description": "Something went wrong with the cast session",
|
||||
"retry": "Try Again",
|
||||
"critical_error_title": "Multiple Errors Detected",
|
||||
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
|
||||
"track_changed": "Track changed successfully",
|
||||
"audio_track_changed": "Audio track changed",
|
||||
"subtitle_track_changed": "Subtitle track changed",
|
||||
"seeking": "Seeking...",
|
||||
"seeking_error": "Failed to seek",
|
||||
"load_failed": "Failed to load media",
|
||||
"load_retry": "Retrying media load..."
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
@@ -471,23 +365,6 @@
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"segment_skip_settings": "Segment Skip Settings",
|
||||
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_intro_description": "Action when intro segment is detected",
|
||||
"skip_outro": "Skip Outro/Credits",
|
||||
"skip_outro_description": "Action when outro/credits segment is detected",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_recap_description": "Action when recap segment is detected",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_commercial_description": "Action when commercial segment is detected",
|
||||
"skip_preview": "Skip Preview",
|
||||
"skip_preview_description": "Action when preview segment is detected",
|
||||
"segment_skip_none": "None",
|
||||
"segment_skip_ask": "Show Skip Button",
|
||||
"segment_skip_auto": "Auto Skip",
|
||||
"autoplay_countdown_seconds": "Player countdown (seconds)",
|
||||
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
@@ -804,6 +681,50 @@
|
||||
"custom_links": {
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Countdown state for Chromecast next-episode autoplay. The watcher
|
||||
* (`useCastAutoplay`) writes it; the casting-player overlay reads it.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export interface CastAutoplayState {
|
||||
/** The episode queued to play next. */
|
||||
nextEpisode: BaseItemDto;
|
||||
/** Seconds left before it loads. */
|
||||
secondsRemaining: number;
|
||||
}
|
||||
|
||||
/** Active cast autoplay countdown, or null when none is running. */
|
||||
export const castAutoplayAtom = atom<CastAutoplayState | null>(null);
|
||||
@@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo } from "react";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||
import { writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "../mmkv";
|
||||
|
||||
@@ -176,9 +175,6 @@ export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// Segment skip behavior options
|
||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
||||
|
||||
// TV Typography scale presets
|
||||
export enum TVTypographyScale {
|
||||
Small = "small",
|
||||
@@ -246,23 +242,10 @@ export type Settings = {
|
||||
jellyseerrServerUrl?: string;
|
||||
useKefinTweaks: boolean;
|
||||
hiddenLibraries?: string[];
|
||||
/** Chromecast profile selection mode. "auto" detects per device. */
|
||||
chromecastProfile: ChromecastProfileMode;
|
||||
/** Optional manual Chromecast video bitrate cap, in bits per second. */
|
||||
chromecastMaxBitrate?: number;
|
||||
enableH265ForChromecast: boolean;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Media segment skip preferences
|
||||
skipIntro: SegmentSkipMode;
|
||||
skipOutro: SegmentSkipMode;
|
||||
skipRecap: SegmentSkipMode;
|
||||
skipCommercial: SegmentSkipMode;
|
||||
skipPreview: SegmentSkipMode;
|
||||
/** Native player next-episode countdown, in seconds. */
|
||||
autoplayCountdownSeconds: number;
|
||||
/** Chromecast next-episode countdown, in seconds. */
|
||||
castAutoplayCountdownSeconds: number;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
@@ -362,19 +345,10 @@ export const defaultValues: Settings = {
|
||||
jellyseerrServerUrl: undefined,
|
||||
useKefinTweaks: false,
|
||||
hiddenLibraries: [],
|
||||
chromecastProfile: "auto",
|
||||
chromecastMaxBitrate: undefined,
|
||||
enableH265ForChromecast: false,
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
autoPlayNextEpisode: true,
|
||||
// Media segment skip defaults
|
||||
skipIntro: "ask",
|
||||
skipOutro: "ask",
|
||||
skipRecap: "ask",
|
||||
skipCommercial: "ask",
|
||||
skipPreview: "ask",
|
||||
autoplayCountdownSeconds: 15,
|
||||
castAutoplayCountdownSeconds: 30,
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildChromecastProfile } from "./buildProfile";
|
||||
import { CONSERVATIVE_CAPABILITIES } from "./capabilities";
|
||||
|
||||
describe("buildChromecastProfile", () => {
|
||||
test("conservative caps produce an H.264-only video codec list", () => {
|
||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
expect(videoCodecProfile?.Codec).toBe("h264");
|
||||
});
|
||||
|
||||
test("HEVC-capable caps include hevc in the video codec list", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
hevc: true,
|
||||
});
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
expect(videoCodecProfile?.Codec).toContain("hevc");
|
||||
});
|
||||
|
||||
test("maxVideoBitrate drives MaxStreamingBitrate", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
maxVideoBitrate: 5_000_000,
|
||||
});
|
||||
expect(profile.MaxStreamingBitrate).toBe(5_000_000);
|
||||
});
|
||||
|
||||
test("maxAudioChannels constrains transcoding profiles", () => {
|
||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
||||
const videoTranscode = profile.TranscodingProfiles?.find(
|
||||
(p) => p.Type === "Video",
|
||||
);
|
||||
expect(videoTranscode?.MaxAudioChannels).toBe("2");
|
||||
});
|
||||
|
||||
test("non-10bit HEVC caps add a video bit-depth condition", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
hevc: true,
|
||||
hevc10bit: false,
|
||||
});
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
const bitDepthCondition = videoCodecProfile?.Conditions?.find(
|
||||
(cond) => cond.Property === "VideoBitDepth",
|
||||
);
|
||||
expect(bitDepthCondition).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import type {
|
||||
DeviceProfile,
|
||||
ProfileCondition,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { ChromecastCapabilities } from "./capabilities";
|
||||
|
||||
/**
|
||||
* Build a Jellyfin `DeviceProfile` for a Chromecast from its detected capabilities.
|
||||
* Replaces the former static `chromecast.ts` / `chromecasth265.ts` profiles.
|
||||
*/
|
||||
export const buildChromecastProfile = (
|
||||
caps: ChromecastCapabilities,
|
||||
): DeviceProfile => {
|
||||
const videoCodecs = caps.hevc ? "hevc,h264" : "h264";
|
||||
const maxHeight = caps.maxResolution === 2160 ? "2160" : "1080";
|
||||
const maxChannels = String(caps.maxAudioChannels);
|
||||
|
||||
const videoConditions: ProfileCondition[] = [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "Height",
|
||||
Value: maxHeight,
|
||||
IsRequired: false,
|
||||
},
|
||||
];
|
||||
// When HEVC is allowed but 10-bit is not, force the server to transcode
|
||||
// 10-bit sources down to 8-bit.
|
||||
if (caps.hevc && !caps.hevc10bit) {
|
||||
videoConditions.push({
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoBitDepth",
|
||||
Value: "8",
|
||||
IsRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Name: "Chromecast Video Profile",
|
||||
MaxStreamingBitrate: caps.maxVideoBitrate,
|
||||
MaxStaticBitrate: caps.maxVideoBitrate,
|
||||
MusicStreamingTranscodingBitrate: 384000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: "Video",
|
||||
Codec: videoCodecs,
|
||||
Conditions: videoConditions,
|
||||
},
|
||||
{
|
||||
Type: "Audio",
|
||||
Codec: "aac,mp3,flac,opus,vorbis",
|
||||
// Force transcode of multichannel audio the receiver cannot output.
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "AudioChannels",
|
||||
Value: maxChannels,
|
||||
IsRequired: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Container: caps.hevc ? "mp4,mkv" : "mp4",
|
||||
Type: "Video",
|
||||
VideoCodec: videoCodecs,
|
||||
AudioCodec: "aac,mp3,opus,vorbis",
|
||||
},
|
||||
{ Container: "mp3", Type: "Audio" },
|
||||
{ Container: "aac", Type: "Audio" },
|
||||
{ Container: "flac", Type: "Audio" },
|
||||
{ Container: "wav", Type: "Audio" },
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Container: "ts",
|
||||
Type: "Video",
|
||||
VideoCodec: videoCodecs,
|
||||
AudioCodec: "aac,mp3",
|
||||
Protocol: "hls",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: maxChannels,
|
||||
MinSegments: 2,
|
||||
BreakOnNonKeyFrames: true,
|
||||
},
|
||||
{
|
||||
Container: "mp3",
|
||||
Type: "Audio",
|
||||
AudioCodec: "mp3",
|
||||
Protocol: "http",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: maxChannels,
|
||||
},
|
||||
{
|
||||
Container: "aac",
|
||||
Type: "Audio",
|
||||
AudioCodec: "aac",
|
||||
Protocol: "http",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: maxChannels,
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [{ Format: "vtt", Method: "Encode" }],
|
||||
};
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CONSERVATIVE_CAPABILITIES, detectCapabilities } from "./capabilities";
|
||||
|
||||
describe("detectCapabilities", () => {
|
||||
test("unknown device falls back to the conservative baseline", () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Some Unknown TV" },
|
||||
{ profileMode: "auto" },
|
||||
);
|
||||
expect(caps).toEqual(CONSERVATIVE_CAPABILITIES);
|
||||
});
|
||||
|
||||
test("null device falls back to the conservative baseline", () => {
|
||||
const caps = detectCapabilities(null, { profileMode: "auto" });
|
||||
expect(caps).toEqual(CONSERVATIVE_CAPABILITIES);
|
||||
});
|
||||
|
||||
test('plain "Chromecast" (gen 1/2/3) gets the conservative baseline', () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "auto" },
|
||||
);
|
||||
expect(caps.hevc).toBe(false);
|
||||
expect(caps.maxResolution).toBe(1080);
|
||||
expect(caps.maxAudioChannels).toBe(2);
|
||||
});
|
||||
|
||||
test("Chromecast Ultra is recognised with HEVC + 4K", () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast Ultra" },
|
||||
{ profileMode: "auto" },
|
||||
);
|
||||
expect(caps.hevc).toBe(true);
|
||||
expect(caps.maxResolution).toBe(2160);
|
||||
});
|
||||
|
||||
test('"force-h264" override disables HEVC even on a capable device', () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast Ultra" },
|
||||
{ profileMode: "force-h264" },
|
||||
);
|
||||
expect(caps.hevc).toBe(false);
|
||||
expect(caps.hevc10bit).toBe(false);
|
||||
});
|
||||
|
||||
test('"force-hevc" override enables HEVC on the conservative baseline', () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "force-hevc" },
|
||||
);
|
||||
expect(caps.hevc).toBe(true);
|
||||
});
|
||||
|
||||
test("maxBitrate override clamps but never raises the bitrate", () => {
|
||||
const lowered = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "auto", maxBitrate: 3_000_000 },
|
||||
);
|
||||
expect(lowered.maxVideoBitrate).toBe(3_000_000);
|
||||
|
||||
const raised = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "auto", maxBitrate: 999_000_000 },
|
||||
);
|
||||
expect(raised.maxVideoBitrate).toBe(
|
||||
CONSERVATIVE_CAPABILITIES.maxVideoBitrate,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Chromecast device capability detection.
|
||||
*
|
||||
* The Cast SDK exposes a device's `modelName` but no codec-level capability API.
|
||||
* We map known model names to a capability profile and fall back to a conservative
|
||||
* baseline (H.264 / 1080p / stereo) for anything unrecognised — a baseline that
|
||||
* cannot produce an unplayable stream on any Cast receiver.
|
||||
*/
|
||||
|
||||
/** Profile selection mode, surfaced as an advanced setting. */
|
||||
export type ChromecastProfileMode = "auto" | "force-hevc" | "force-h264";
|
||||
|
||||
export interface ChromecastCapabilities {
|
||||
/** HEVC 8-bit (Main profile) decode support. */
|
||||
hevc: boolean;
|
||||
/** HEVC 10-bit (Main10) decode support. */
|
||||
hevc10bit: boolean;
|
||||
/** Maximum video resolution height. */
|
||||
maxResolution: 1080 | 2160;
|
||||
/** Maximum video bitrate in bits per second. */
|
||||
maxVideoBitrate: number;
|
||||
/** Maximum audio channels the receiver can output. */
|
||||
maxAudioChannels: number;
|
||||
}
|
||||
|
||||
/** Minimal shape we need from the Cast SDK `Device` — keeps this module import-free. */
|
||||
interface DeviceLike {
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
/** Overrides derived from user settings. */
|
||||
export interface CapabilityOverrides {
|
||||
profileMode: ChromecastProfileMode;
|
||||
/** Optional manual cap in bits per second. */
|
||||
maxBitrate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline for a 1st/2nd/3rd-gen Chromecast and any unrecognised device.
|
||||
* `maxVideoBitrate` is an initial estimate — see docs/chromecast-test-matrix.md.
|
||||
*/
|
||||
export const CONSERVATIVE_CAPABILITIES: ChromecastCapabilities = {
|
||||
hevc: false,
|
||||
hevc10bit: false,
|
||||
maxResolution: 1080,
|
||||
maxVideoBitrate: 8_000_000,
|
||||
maxAudioChannels: 2,
|
||||
};
|
||||
|
||||
/** Known Cast devices keyed by `Device.modelName`. Unlisted models stay conservative. */
|
||||
const CHROMECAST_REGISTRY: Record<string, ChromecastCapabilities> = {
|
||||
"Chromecast Ultra": {
|
||||
hevc: true,
|
||||
hevc10bit: false,
|
||||
maxResolution: 2160,
|
||||
maxVideoBitrate: 20_000_000,
|
||||
maxAudioChannels: 6,
|
||||
},
|
||||
"Chromecast with Google TV": {
|
||||
hevc: true,
|
||||
hevc10bit: true,
|
||||
maxResolution: 2160,
|
||||
maxVideoBitrate: 20_000_000,
|
||||
maxAudioChannels: 6,
|
||||
},
|
||||
"Google TV Streamer": {
|
||||
hevc: true,
|
||||
hevc10bit: true,
|
||||
maxResolution: 2160,
|
||||
maxVideoBitrate: 25_000_000,
|
||||
maxAudioChannels: 8,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective capabilities for a Cast device.
|
||||
* Registry lookup → conservative fallback → user overrides applied last.
|
||||
*/
|
||||
export const detectCapabilities = (
|
||||
device: DeviceLike | null,
|
||||
overrides: CapabilityOverrides,
|
||||
): ChromecastCapabilities => {
|
||||
const base =
|
||||
(device?.modelName && CHROMECAST_REGISTRY[device.modelName]) ||
|
||||
CONSERVATIVE_CAPABILITIES;
|
||||
|
||||
const caps: ChromecastCapabilities = { ...base };
|
||||
|
||||
if (overrides.profileMode === "force-hevc") {
|
||||
caps.hevc = true;
|
||||
} else if (overrides.profileMode === "force-h264") {
|
||||
caps.hevc = false;
|
||||
caps.hevc10bit = false;
|
||||
}
|
||||
|
||||
if (overrides.maxBitrate && overrides.maxBitrate > 0) {
|
||||
caps.maxVideoBitrate = Math.min(caps.maxVideoBitrate, overrides.maxBitrate);
|
||||
}
|
||||
|
||||
return caps;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isLoadFailedError } from "./castErrors";
|
||||
|
||||
describe("isLoadFailedError", () => {
|
||||
test("recognises a status 2100 error message", () => {
|
||||
const error = new Error(
|
||||
"java.lang.Exception: Media control channel status code 2100",
|
||||
);
|
||||
expect(isLoadFailedError(error)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for unrelated errors", () => {
|
||||
expect(isLoadFailedError(new Error("network timeout"))).toBe(false);
|
||||
});
|
||||
|
||||
test("handles non-Error values without throwing", () => {
|
||||
expect(isLoadFailedError("status code 2100")).toBe(true);
|
||||
expect(isLoadFailedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Cast load error classification. Kept dependency-free so it is unit-testable
|
||||
* without pulling React Native modules into the test runtime.
|
||||
*/
|
||||
|
||||
/** True when an error is a Cast "LOAD_FAILED" (status 2100) rejection. */
|
||||
export const isLoadFailedError = (error: unknown): boolean => {
|
||||
if (error == null) return false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("2100");
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Unified Chromecast media loading.
|
||||
*
|
||||
* Owns the getStreamUrl → buildCastMediaInfo → loadMedia sequence that was
|
||||
* previously duplicated across PlayButton and the casting player. Builds the
|
||||
* device profile from detected capabilities and retries once with a forced
|
||||
* conservative profile when the receiver rejects the initial load (status 2100).
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import { buildChromecastProfile } from "@/utils/casting/buildProfile";
|
||||
import {
|
||||
type ChromecastProfileMode,
|
||||
detectCapabilities,
|
||||
} from "@/utils/casting/capabilities";
|
||||
import { isLoadFailedError } from "@/utils/casting/castErrors";
|
||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||
import { resolveSelection } from "@/utils/casting/selection";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
|
||||
export interface CastLoadOptions {
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
maxBitrate?: number;
|
||||
mediaSourceId?: string;
|
||||
startPositionMs?: number;
|
||||
}
|
||||
|
||||
export interface CastLoadParams {
|
||||
client: RemoteMediaClient;
|
||||
/** Cast device — only `modelName` is read, for capability detection. */
|
||||
device: { modelName?: string } | null;
|
||||
api: Api;
|
||||
item: BaseItemDto;
|
||||
userId: string;
|
||||
profileMode: ChromecastProfileMode;
|
||||
/** Manual bitrate cap from settings, in bits per second. */
|
||||
maxBitrateSetting?: number;
|
||||
options?: CastLoadOptions;
|
||||
}
|
||||
|
||||
export type CastLoadResult = { ok: true } | { ok: false; error: unknown };
|
||||
|
||||
const attemptLoad = async (
|
||||
params: CastLoadParams,
|
||||
caps: Parameters<typeof buildChromecastProfile>[0],
|
||||
): Promise<void> => {
|
||||
const { api, item, userId, client, options } = params;
|
||||
const profile = buildChromecastProfile(caps);
|
||||
|
||||
const selection = resolveSelection(item, {
|
||||
mediaSourceId: options?.mediaSourceId,
|
||||
audioStreamIndex: options?.audioStreamIndex,
|
||||
subtitleStreamIndex: options?.subtitleStreamIndex,
|
||||
maxBitrate: options?.maxBitrate,
|
||||
});
|
||||
|
||||
const startPositionMs = options?.startPositionMs ?? 0;
|
||||
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
startTimeTicks: Math.floor(startPositionMs * 10000),
|
||||
deviceProfile: profile,
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
maxStreamingBitrate: selection.maxBitrate,
|
||||
mediaSourceId: selection.mediaSourceId,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
throw new Error("getStreamUrl returned no URL");
|
||||
}
|
||||
|
||||
const playMethod: "Transcode" | "DirectPlay" = data.mediaSource
|
||||
?.TranscodingUrl
|
||||
? "Transcode"
|
||||
: "DirectPlay";
|
||||
|
||||
await client.loadMedia({
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
playSessionId: data.sessionId ?? undefined,
|
||||
selection,
|
||||
playMethod,
|
||||
}),
|
||||
startTime: startPositionMs / 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Load media onto the connected Chromecast.
|
||||
* On a status-2100 rejection, retries once with a forced conservative profile.
|
||||
*/
|
||||
export const loadCastMedia = async (
|
||||
params: CastLoadParams,
|
||||
): Promise<CastLoadResult> => {
|
||||
const caps = detectCapabilities(params.device, {
|
||||
profileMode: params.profileMode,
|
||||
maxBitrate: params.maxBitrateSetting,
|
||||
});
|
||||
|
||||
try {
|
||||
await attemptLoad(params, caps);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (!isLoadFailedError(error)) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
// Downgrade-on-failure: one retry with the safest possible profile.
|
||||
// The bitrate cap must also be applied to the explicit getStreamUrl
|
||||
// `maxBitrate` request param — Jellyfin uses that as the effective
|
||||
// ceiling, so the conservative profile alone would not lower it.
|
||||
try {
|
||||
const fallback = detectCapabilities(params.device, {
|
||||
profileMode: "force-h264",
|
||||
});
|
||||
const FALLBACK_MAX_BITRATE = 4_000_000;
|
||||
const fallbackParams: CastLoadParams = {
|
||||
...params,
|
||||
options: {
|
||||
...params.options,
|
||||
maxBitrate: Math.min(
|
||||
params.options?.maxBitrate ?? Number.POSITIVE_INFINITY,
|
||||
FALLBACK_MAX_BITRATE,
|
||||
),
|
||||
},
|
||||
};
|
||||
await attemptLoad(fallbackParams, {
|
||||
...fallback,
|
||||
maxVideoBitrate: FALLBACK_MAX_BITRATE,
|
||||
maxAudioChannels: 2,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (retryError) {
|
||||
return { ok: false, error: retryError };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { findNextEpisode } from "./episodes";
|
||||
|
||||
const ep = (id: string) => ({ Id: id });
|
||||
|
||||
describe("findNextEpisode", () => {
|
||||
test("returns the episode after the current one", () => {
|
||||
expect(findNextEpisode([ep("a"), ep("b"), ep("c")], "b")).toEqual(ep("c"));
|
||||
});
|
||||
test("returns null on the last episode", () => {
|
||||
expect(findNextEpisode([ep("a"), ep("b")], "b")).toBeNull();
|
||||
});
|
||||
test("returns null when the current id is not found", () => {
|
||||
expect(findNextEpisode([ep("a"), ep("b")], "x")).toBeNull();
|
||||
});
|
||||
test("returns null for an empty list", () => {
|
||||
expect(findNextEpisode([], "a")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Episode-list helpers for the casting player and the autoplay watcher.
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
UserDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
/** The episode following `currentId` in `episodes`, or null if none / not found. */
|
||||
export const findNextEpisode = (
|
||||
episodes: BaseItemDto[],
|
||||
currentId: string | null | undefined,
|
||||
): BaseItemDto | null => {
|
||||
const index = episodes.findIndex((e) => e.Id === currentId);
|
||||
if (index < 0 || index + 1 >= episodes.length) return null;
|
||||
return episodes[index + 1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch every episode of the series that owns the current episode.
|
||||
* Mirrors the call previously inlined in `useCastEpisodes`: no season filter,
|
||||
* and the same `userId` quirk (undefined when an access token is present, else
|
||||
* the empty string) so the request payload stays byte-identical.
|
||||
*/
|
||||
export const fetchSeriesEpisodes = async (
|
||||
api: Api,
|
||||
_user: UserDto,
|
||||
seriesId: string,
|
||||
): Promise<BaseItemDto[]> => {
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId,
|
||||
userId: api.accessToken ? undefined : "",
|
||||
});
|
||||
// Drop "Virtual" (missing) episodes — e.g. an empty Specials/Season 0 entry
|
||||
// that has no media file. They must not appear in the cast episode list nor
|
||||
// be offered as prev/next/autoplay targets (they can't be cast).
|
||||
return (res.data.Items ?? []).filter((e) => e.LocationType !== "Virtual");
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Helper Functions
|
||||
* Common utilities for casting protocols
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format milliseconds to HH:MM:SS or MM:SS
|
||||
*/
|
||||
export const formatTime = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate ending time based on current progress and duration.
|
||||
* Uses locale-aware formatting when available.
|
||||
*/
|
||||
export const calculateEndingTime = (
|
||||
currentMs: number,
|
||||
durationMs: number,
|
||||
): string => {
|
||||
const remainingMs = durationMs - currentMs;
|
||||
const endTime = new Date(Date.now() + remainingMs);
|
||||
|
||||
try {
|
||||
return endTime.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
// Fallback for environments without Intl support
|
||||
const hours = endTime.getHours();
|
||||
const minutes = endTime.getMinutes();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get poster URL for item with specified dimensions
|
||||
*/
|
||||
export const getPosterUrl = (
|
||||
baseUrl: string | undefined,
|
||||
itemId: string | undefined,
|
||||
tag: string | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
): string | null => {
|
||||
if (!baseUrl || !itemId) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
maxWidth: width.toString(),
|
||||
maxHeight: height.toString(),
|
||||
quality: "90",
|
||||
...(tag && { tag }),
|
||||
});
|
||||
|
||||
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncate title to max length with ellipsis
|
||||
*/
|
||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
||||
if (maxLength < 4) return title.substring(0, maxLength);
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.substring(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current time is within a segment
|
||||
*/
|
||||
export const isWithinSegment = (
|
||||
currentMs: number,
|
||||
segment: { start: number; end: number } | null,
|
||||
): boolean => {
|
||||
if (!segment) return false;
|
||||
const currentSeconds = currentMs / 1000;
|
||||
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format trickplay time from {hours, minutes, seconds} to display string.
|
||||
* Produces "H:MM:SS" when hours > 0, otherwise "MM:SS".
|
||||
*/
|
||||
export const formatTrickplayTime = (time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}): string => {
|
||||
const mm = String(time.minutes).padStart(2, "0");
|
||||
const ss = String(time.seconds).padStart(2, "0");
|
||||
return time.hours > 0 ? `${time.hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Shared helper to build Chromecast media metadata.
|
||||
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { MediaStreamType } from "react-native-google-cast";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
/**
|
||||
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
|
||||
*
|
||||
* NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH
|
||||
* from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml"
|
||||
* actually BREAKS playback on many receivers. Always use "video/mp4" unless
|
||||
* you have a custom receiver that explicitly handles other MIME types.
|
||||
*/
|
||||
export const buildCastMediaInfo = ({
|
||||
item,
|
||||
streamUrl,
|
||||
api,
|
||||
contentType,
|
||||
isLive = false,
|
||||
playSessionId,
|
||||
selection,
|
||||
playMethod,
|
||||
}: {
|
||||
item: BaseItemDto;
|
||||
streamUrl: string;
|
||||
api: Api;
|
||||
/** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */
|
||||
contentType?: string;
|
||||
/** Set true for live TV streams to use MediaStreamType.LIVE. */
|
||||
isLive?: boolean;
|
||||
/** Jellyfin PlaySessionId, embedded in customData for progress reporting. */
|
||||
playSessionId?: string;
|
||||
/** Active track / quality / version selection, embedded in customData. */
|
||||
selection?: CastSelection;
|
||||
/** "Transcode" when the stream is a server transcode, else "DirectPlay". */
|
||||
playMethod?: "Transcode" | "DirectPlay";
|
||||
}) => {
|
||||
if (!item.Id) {
|
||||
throw new Error("Missing item.Id for media load — cannot build contentId");
|
||||
}
|
||||
|
||||
const itemId: string = item.Id;
|
||||
const streamDuration = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
const buildImages = (urls: (string | null | undefined)[]) =>
|
||||
urls.filter(Boolean).map((url) => ({ url: url as string }));
|
||||
|
||||
const buildItemMetadata = () => {
|
||||
if (item.Type === "Episode") {
|
||||
return {
|
||||
type: "tvShow" as const,
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: buildImages([
|
||||
getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") {
|
||||
return {
|
||||
type: "movie" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "generic" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
const metadata = buildItemMetadata();
|
||||
|
||||
// Build a slim customData payload with only the fields the casting-player needs.
|
||||
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
|
||||
// especially for movies with many chapters, media sources, and people.
|
||||
const slimCustomData: Partial<BaseItemDto> & {
|
||||
playSessionId?: string;
|
||||
selection?: CastSelection;
|
||||
playMethod?: "Transcode" | "DirectPlay";
|
||||
} = {
|
||||
playSessionId,
|
||||
selection,
|
||||
playMethod,
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
SeriesName: item.SeriesName,
|
||||
SeriesId: item.SeriesId,
|
||||
SeasonId: item.SeasonId,
|
||||
IndexNumber: item.IndexNumber,
|
||||
ParentIndexNumber: item.ParentIndexNumber,
|
||||
ImageTags: item.ImageTags,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
Overview: item.Overview,
|
||||
MediaStreams: item.MediaStreams,
|
||||
MediaSources: item.MediaSources?.map((src) => ({
|
||||
Id: src.Id,
|
||||
Bitrate: src.Bitrate,
|
||||
Container: src.Container,
|
||||
Name: src.Name,
|
||||
DefaultAudioStreamIndex: src.DefaultAudioStreamIndex,
|
||||
DefaultSubtitleStreamIndex: src.DefaultSubtitleStreamIndex,
|
||||
})),
|
||||
UserData: item.UserData
|
||||
? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
contentId: itemId,
|
||||
contentUrl: streamUrl,
|
||||
contentType: contentType || "video/mp4",
|
||||
streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED,
|
||||
streamDuration,
|
||||
customData: slimCustomData,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
resolveDefaultAudioIndex,
|
||||
resolveSelection,
|
||||
selectionsEqual,
|
||||
} from "./selection";
|
||||
|
||||
const item: BaseItemDto = {
|
||||
Id: "item-1",
|
||||
MediaSources: [
|
||||
{
|
||||
Id: "src-a",
|
||||
DefaultAudioStreamIndex: 2,
|
||||
DefaultSubtitleStreamIndex: 3,
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0 },
|
||||
{ Type: "Audio", Index: 1, IsDefault: false },
|
||||
{ Type: "Audio", Index: 2, IsDefault: true },
|
||||
{ Type: "Subtitle", Index: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
Id: "src-b",
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0 },
|
||||
{ Type: "Audio", Index: 1, IsDefault: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("resolveDefaultAudioIndex", () => {
|
||||
test("uses the source's DefaultAudioStreamIndex when present", () => {
|
||||
expect(resolveDefaultAudioIndex(item, "src-a")).toBe(2);
|
||||
});
|
||||
|
||||
test("falls back to the first audio stream when no default flag", () => {
|
||||
expect(resolveDefaultAudioIndex(item, "src-b")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSelection", () => {
|
||||
test("fills every field from server defaults of the first source", () => {
|
||||
const sel = resolveSelection(item, {});
|
||||
expect(sel.mediaSourceId).toBe("src-a");
|
||||
expect(sel.audioStreamIndex).toBe(2);
|
||||
expect(sel.subtitleStreamIndex).toBe(3);
|
||||
expect(sel.maxBitrate).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a partial overrides defaults and keeps the rest", () => {
|
||||
const sel = resolveSelection(item, {
|
||||
audioStreamIndex: 1,
|
||||
maxBitrate: 4_000_000,
|
||||
});
|
||||
expect(sel.audioStreamIndex).toBe(1);
|
||||
expect(sel.maxBitrate).toBe(4_000_000);
|
||||
expect(sel.subtitleStreamIndex).toBe(3);
|
||||
});
|
||||
|
||||
test("switching version resolves that version's defaults", () => {
|
||||
const sel = resolveSelection(item, { mediaSourceId: "src-b" });
|
||||
expect(sel.mediaSourceId).toBe("src-b");
|
||||
expect(sel.audioStreamIndex).toBe(1);
|
||||
expect(sel.subtitleStreamIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionsEqual", () => {
|
||||
test("true for identical selections", () => {
|
||||
const a = {
|
||||
mediaSourceId: "s",
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: -1,
|
||||
};
|
||||
expect(selectionsEqual(a, { ...a })).toBe(true);
|
||||
});
|
||||
|
||||
test("false when any field differs", () => {
|
||||
const a = {
|
||||
mediaSourceId: "s",
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: -1,
|
||||
};
|
||||
expect(selectionsEqual(a, { ...a, audioStreamIndex: 2 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Cast selection resolution — pure helpers, no React Native imports, so they
|
||||
* are unit-testable under `bun test`.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
|
||||
/**
|
||||
* Resolve the default audio stream index for an item / media source.
|
||||
* Prefers the source's `DefaultAudioStreamIndex`, then the first audio stream.
|
||||
*/
|
||||
export const resolveDefaultAudioIndex = (
|
||||
item: BaseItemDto,
|
||||
mediaSourceId?: string,
|
||||
): number | undefined => {
|
||||
const source = mediaSourceId
|
||||
? item.MediaSources?.find((s) => s.Id === mediaSourceId)
|
||||
: item.MediaSources?.[0];
|
||||
if (source?.DefaultAudioStreamIndex != null) {
|
||||
return source.DefaultAudioStreamIndex;
|
||||
}
|
||||
const streams = source?.MediaStreams ?? item.MediaStreams;
|
||||
const audio =
|
||||
streams?.find((s) => s.Type === "Audio" && s.IsDefault) ??
|
||||
streams?.find((s) => s.Type === "Audio");
|
||||
return audio?.Index ?? undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete a partial selection with the item's server defaults.
|
||||
* Used on first load, on episode change, and when switching version.
|
||||
*/
|
||||
export const resolveSelection = (
|
||||
item: BaseItemDto,
|
||||
partial: Partial<CastSelection>,
|
||||
): CastSelection => {
|
||||
const mediaSourceId =
|
||||
partial.mediaSourceId ?? item.MediaSources?.[0]?.Id ?? "";
|
||||
const source = item.MediaSources?.find((s) => s.Id === mediaSourceId);
|
||||
|
||||
return {
|
||||
mediaSourceId,
|
||||
audioStreamIndex:
|
||||
partial.audioStreamIndex ??
|
||||
resolveDefaultAudioIndex(item, mediaSourceId) ??
|
||||
-1,
|
||||
subtitleStreamIndex:
|
||||
partial.subtitleStreamIndex ?? source?.DefaultSubtitleStreamIndex ?? -1,
|
||||
maxBitrate: partial.maxBitrate,
|
||||
};
|
||||
};
|
||||
|
||||
/** True when two selections are equivalent — used to reconcile optimistic state. */
|
||||
export const selectionsEqual = (a: CastSelection, b: CastSelection): boolean =>
|
||||
a.mediaSourceId === b.mediaSourceId &&
|
||||
a.audioStreamIndex === b.audioStreamIndex &&
|
||||
a.subtitleStreamIndex === b.subtitleStreamIndex &&
|
||||
a.maxBitrate === b.maxBitrate;
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Types and Options
|
||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
||||
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
export type CastProtocol = "chromecast";
|
||||
|
||||
export interface CastDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: CastProtocol;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface CastPlayerState {
|
||||
isConnected: boolean;
|
||||
isPlaying: boolean;
|
||||
currentItem: BaseItemDto | null;
|
||||
currentDevice: CastDevice | null;
|
||||
protocol: CastProtocol | null;
|
||||
progress: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isBuffering: boolean;
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
index: number;
|
||||
language: string;
|
||||
codec: string;
|
||||
displayTitle: string;
|
||||
}
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
language: string;
|
||||
codec: string;
|
||||
displayTitle: string;
|
||||
isForced: boolean;
|
||||
}
|
||||
|
||||
export interface MediaSource {
|
||||
id: string;
|
||||
name: string;
|
||||
bitrate?: number;
|
||||
container: string;
|
||||
}
|
||||
|
||||
export const CASTING_CONSTANTS = {
|
||||
POSTER_WIDTH: 300,
|
||||
POSTER_HEIGHT: 450,
|
||||
ANIMATION_DURATION: 300,
|
||||
CONTROL_HIDE_DELAY: 5000,
|
||||
PROGRESS_UPDATE_INTERVAL: 1000,
|
||||
SEEK_FORWARD_SECONDS: 10,
|
||||
SEEK_BACKWARD_SECONDS: 10,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_CAST_STATE: CastPlayerState = {
|
||||
isConnected: false,
|
||||
isPlaying: false,
|
||||
currentItem: null,
|
||||
currentDevice: null,
|
||||
protocol: null,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
volume: 0.5,
|
||||
isBuffering: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* What is currently loaded on the cast — the single source of truth for
|
||||
* audio / subtitle / quality / version selection.
|
||||
*/
|
||||
export interface CastSelection {
|
||||
/** MediaSource (version) id. */
|
||||
mediaSourceId: string;
|
||||
/** Absolute MediaStream index of the audio track. */
|
||||
audioStreamIndex: number;
|
||||
/** Absolute MediaStream index of the subtitle track; -1 = subtitles off. */
|
||||
subtitleStreamIndex: number;
|
||||
/** Quality cap in bits/second; undefined = unconstrained. */
|
||||
maxBitrate?: number;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Chromecast player configuration and types
|
||||
*/
|
||||
|
||||
export interface ChromecastSegmentData {
|
||||
intro: { start: number; end: number } | null;
|
||||
credits: { start: number; end: number } | null;
|
||||
recap: { start: number; end: number } | null;
|
||||
commercial: { start: number; end: number }[];
|
||||
preview: { start: number; end: number }[];
|
||||
}
|
||||