Compare commits

..

17 Commits

Author SHA1 Message Date
Alex Kim
613ad1effc Final touches 2026-06-05 23:36:34 +10:00
Alex Kim
2df63eb63c Redo navigation for sync-play support 2026-06-05 23:13:52 +10:00
Alex Kim
ab42e8a576 Add PIP support for syncplay 2026-06-05 21:42:06 +10:00
Alex Kim
0e93cd5385 Improve code quality 2026-06-05 20:00:55 +10:00
Alex Kim
96b4121c1f Merge branch 'develop' into feature/sync-play 2026-06-05 17:12:36 +10:00
lance chant
f7033e7abb fix: player reporting when exiting and app splash load (#1662) 2026-06-05 08:14:45 +02:00
Alex
0d796d01b8 feat(mpv-ios): Fix controls not pressable after resuming from PIP (#1667)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-04 21:44:04 +10:00
Fredrik Burmester
46d96d5965 fix(music): use white tab labels instead of default blue (#1657)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 23:36:50 +02:00
Kylian Ronfleux--Corail
7d16e7d5c7 fix: Improve perf download database and correct crash when changing episode (#1547)
Co-authored-by: BuildTools <unconfigured@null.spigotmc.org>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 22:37:16 +02:00
Alex
ceb9b5a1ae fix(downloads): poster images not showing for downloaded content (#1652)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 19:47:14 +02:00
Gauvain
1144ff5049 fix(player): correct prev/next episode buttons at boundaries & for missing episodes (#1649) 2026-06-02 19:12:21 +02:00
Alex
4d508a4315 fix(platform-dropdown-component): Auto-size iOS trigger wrapper to pr… (#1650) 2026-06-02 18:40:47 +02:00
Alex
915a4febbb fix(player): respect safe area in chapter list and refine UX (#1653) 2026-06-02 22:31:49 +10:00
Alex
88163eb531 fix(mpv): release audio session on player exit so other apps' audio resumes (#1651)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-02 19:20:56 +10:00
Alex Kim
27c400a54a Merge branch 'develop' into feature/sync-play 2026-05-31 20:47:27 +10:00
Alex Kim
261f7cc0cd Merge branch 'develop' into feature/sync-play 2026-05-31 19:05:32 +10:00
Alex Kim
d06daef933 feat(sync-play): squash feature/sync-play 2026-05-31 19:04:14 +10:00
113 changed files with 5321 additions and 8529 deletions

View File

@@ -3,7 +3,7 @@
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs, It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support. and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies ## Main Technologies
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- `scripts/` Automation scripts (Node.js, Bash) - `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins - `plugins/` Expo/Metro plugins
## Code Quality Standards ## Coding Standards
**CRITICAL: Code must be production-ready, reliable, and maintainable**
### Type Safety
- Use TypeScript for ALL files (no .js files) - Use TypeScript for ALL files (no .js files)
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
- When facing type issues, create proper type definitions and helper functions instead of using `any`
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
- Enable and respect strict TypeScript compiler options
- Define explicit return types for functions
- Use discriminated unions for complex state
### Code Reliability
- Implement comprehensive error handling with try-catch blocks
- Validate all external inputs (API responses, user input, query params)
- Handle edge cases explicitly (empty arrays, null, undefined)
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
- Add runtime checks for critical operations
- Implement proper loading and error states in components
### Best Practices
- Use descriptive English names for variables, functions, and components - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- Follow BiomeJS formatting and linting rules - Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely - Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries - Implement proper error boundaries
- Use React.memo() for performance optimization when needed - Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Write self-documenting code with clear intent
- Add comments only when code complexity requires explanation
## API Integration ## API Integration
@@ -108,18 +85,6 @@ Exemples:
- `fix(auth): handle expired JWT tokens` - `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK` - `chore(deps): update Jellyfin SDK`
## Internationalization (i18n)
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
- **NEVER add or remove keys** - Crowdin manages the key structure
- **Editing translation values is safe** - Bidirectional sync handles merges
- Prefer letting Crowdin translators update values, but direct edits work if needed
- **Crowdin workflow**:
- New keys added to `en.json` sync to Crowdin automatically
- Approved translations sync back to language files via GitHub integration
- The source of truth is `en.json` for structure, Crowdin for translations
## Special Instructions ## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV) - Prioritize cross-platform compatibility (mobile + TV)

View File

@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
const SyncPlayButtonComponent = Platform.isTV
? null
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
@@ -33,6 +36,7 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast background='transparent' />
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>

View File

@@ -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",
},
];

View File

@@ -40,6 +40,8 @@ const Layout = () => {
keyboardDismissMode='none' keyboardDismissMode='none'
screenOptions={{ screenOptions={{
tabBarBounces: true, tabBarBounces: true,
tabBarActiveTintColor: "#FFFFFF",
tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE, fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600", fontWeight: "600",

View File

@@ -11,8 +11,6 @@ import type {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler"; import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -141,8 +139,6 @@ export default function TabLayout() {
}} }}
/> />
</NativeTabs> </NativeTabs>
<CastingMiniPlayer />
<CastAutoplayWatcher />
<MiniPlayerBar /> <MiniPlayerBar />
<MusicPlaybackEngine /> <MusicPlaybackEngine />
</View> </View>

View File

@@ -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>
</>
);
}

View File

@@ -31,9 +31,11 @@ import {
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { import {
@@ -49,9 +51,10 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider"; import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -59,10 +62,6 @@ import {
getMpvSubtitleId, getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils"; } from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native"; import { generateDeviceProfile } from "../../../utils/profiles/native";
@@ -80,6 +79,11 @@ export default function DirectPlayerPage() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); const [isPipMode, setIsPipMode] = useState(false);
// Keep the global WebSocket open while in PiP so SyncPlay commands
// (and any other server pushes) keep flowing while iOS treats the
// app as backgrounded. See `WebSocketProvider.acquireKeepAlive`.
useKeepWebSocketAlive(isPipMode);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">( const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default", "default",
); );
@@ -131,6 +135,7 @@ export default function DirectPlayerPage() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
syncPlay: syncPlayStr,
} = useLocalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -140,9 +145,23 @@ export default function DirectPlayerPage() {
offline: string; offline: string;
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
/** Whether playback was initiated by SyncPlay */
syncPlay?: string;
}>(); }>();
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
const openedViaSyncPlay = syncPlayStr === "true";
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
// SyncPlay integration
const syncPlay = useSyncPlay();
const {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
notifyBuffering,
} = syncPlay;
const offline = offlineStr === "true"; const offline = offlineStr === "true";
// Audio index: use URL param if provided, otherwise use stored index for offline playback // Audio index: use URL param if provided, otherwise use stored index for offline playback
@@ -277,6 +296,11 @@ export default function DirectPlayerPage() {
}; };
if (itemId) { 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(); fetchItemData();
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
@@ -319,6 +343,12 @@ export default function DirectPlayerPage() {
return null; 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; let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) { if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath; const url = downloadedItem.videoFilePath;
@@ -391,6 +421,7 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {
@@ -406,25 +437,103 @@ export default function DirectPlayerPage() {
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
// SyncPlay: Connect player controls when video is ready
useEffect(() => {
if (!isVideoLoaded || !videoRef.current || offline) {
setPlayerControls(null);
return;
}
const controls: PlayerControls = {
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (positionMs: number) => {
const positionSec = positionMs / 1000;
console.log(
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
);
videoRef.current?.seekTo(positionSec);
},
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
getSpeed: () => currentPlaybackSpeed,
getCurrentPosition: () => progress.get(),
isPlaying: () => isPlaying,
isBuffering: () => isBuffering,
};
setPlayerControls(controls);
return () => {
setPlayerControls(null);
};
}, [
isVideoLoaded,
offline,
isPlaying,
isBuffering,
currentPlaybackSpeed,
progress,
setPlayerControls,
]);
// SyncPlay: Report buffering/ready state to server.
//
// CRITICAL: We must report `buffering` to the server *during* initial
// load (before `isVideoLoaded`), otherwise the server treats us as ready
// and proceeds without waiting for us. jellyfin-web reports this for
// free via the HTML5 video element's `waiting` event; for us, the
// initial load itself is the buffering window.
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const isLocallyReady = isVideoLoaded && !isBuffering;
// notifyBuffering routes through the debouncer in PlaybackCore so
// re-renders during a stall don't spam the server.
notifyBuffering(!isLocallyReady);
}, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]);
const togglePlay = async () => {
lightHapticFeedback();
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.playPause();
return;
}
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
};
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId || offline || !api) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api).onPlaybackStopped({ await getPlaystateApi(api).reportPlaybackStopped({
itemId: item.Id, playbackStopInfo: {
mediaSourceId: mediaSourceId, ItemId: item.Id,
positionTicks: currentTimeInTicks, MediaSourceId: mediaSourceId,
playSessionId: stream.sessionId, PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
},
}); });
}, [ }, [api, item, mediaSourceId, stream, progress, offline]);
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping // Update URL with final playback position before stopping
@@ -442,9 +551,10 @@ export default function DirectPlayerPage() {
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => { return () => {
reportPlaybackStopped();
beforeRemoveListener(); beforeRemoveListener();
}; };
}, [navigation, stop]); }, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback((): const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo | PlaybackProgressInfo
@@ -479,35 +589,6 @@ export default function DirectPlayerPage() {
isMuted, isMuted,
]); ]);
// Declared after currentPlayStateInfo so the dependency array can reference
// it without hitting the temporal dead zone.
const togglePlay = useCallback(async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
}, [
lightHapticFeedback,
isPlaying,
currentPlayStateInfo,
playbackManager,
offline,
api,
]);
const lastUrlUpdateTime = useSharedValue(0); const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false); const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
@@ -650,10 +731,12 @@ export default function DirectPlayerPage() {
const startPos = ticksToSeconds(startTicks); const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming // Build source config - headers only needed for online streaming
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
const shouldAutoplay = !openedViaSyncPlay;
const source: MpvVideoSource = { const source: MpvVideoSource = {
url: stream.url, url: stream.url,
startPosition: startPos, startPosition: startPos,
autoplay: true, autoplay: shouldAutoplay,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences // Pass cache/buffer settings from user preferences
@@ -848,6 +931,41 @@ export default function DirectPlayerPage() {
[], [],
); );
// PiP playback controls. When SyncPlay is active, the native side
// is told to *delegate* these via `syncPlayDelegated`, so the OS
// play/pause/skip buttons emit these events instead of poking MPV
// directly. We route them through the SyncPlay controller so the
// server broadcasts a command to every group member (including us).
const _onPipPlayRequest = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: PiP play → controller.playPause()");
syncPlayController.playPause();
}
}, [isSyncPlayEnabled, syncPlayController]);
const _onPipPauseRequest = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: PiP pause → controller.playPause()");
syncPlayController.playPause();
}
}, [isSyncPlayEnabled, syncPlayController]);
const _onPipSkipRequest = useCallback(
(e: {
nativeEvent: { targetSeconds: number; intervalSeconds: number };
}) => {
if (!isSyncPlayEnabled || !syncPlayController) return;
const { targetSeconds } = e.nativeEvent;
// SyncPlay seek takes ticks (1 s = 10_000_000 ticks).
const ticks = Math.max(0, Math.round(targetSeconds * 10_000_000));
console.log(
`SyncPlay: PiP skip → controller.seek(${targetSeconds}s = ${ticks} ticks)`,
);
syncPlayController.seek(ticks);
},
[isSyncPlayEnabled, syncPlayController],
);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting // Add useEffect to handle mounting
@@ -872,10 +990,21 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.(); videoRef.current?.pause?.();
}, []); }, []);
const seek = useCallback((position: number) => { const seek = useCallback(
// MPV expects seconds, convert from ms (position: number) => {
videoRef.current?.seekTo?.(position / 1000); // Route through SyncPlay when active. `position` is in ms; the
}, []); // controller takes ticks (1 ms = 10000 ticks).
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: seek requested via SyncPlay", position);
syncPlayController.seek(Math.round(position * 10000));
return;
}
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
},
[isSyncPlayEnabled, syncPlayController],
);
// TV audio track change handler // TV audio track change handler
const handleAudioIndexChange = useCallback( const handleAudioIndexChange = useCallback(
@@ -936,47 +1065,6 @@ export default function DirectPlayerPage() {
return (await videoRef.current?.getTechnicalInfo?.()) ?? {}; return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
}, []); }, []);
// App-wide remote control: wrap the player's existing handlers so remote
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
const playbackController = useMemo<PlaybackController>(
() => ({
// togglePlay flips play/pause and reports progress to the server.
playPause: () => {
void togglePlay();
},
pause: () => {
pause();
},
unpause: () => {
play();
},
stop: () => {
stop();
},
// PlaybackController seeks in ms; the player's seek already expects ms.
seek: (positionMs: number) => {
seek(positionMs);
},
// The player screen has no episode-loading path of its own — episode
// navigation is handled inside <Controls> via router replacement — so
// next/previous are honest no-ops here.
next: () => {},
previous: () => {},
// Volume is device-level (react-native-volume-manager); the slider sends
// 0-1 while setVolumeCb expects 0-100.
setVolume: (level: number) => {
void setVolumeCb(level * 100);
},
toggleMute: () => {
void toggleMuteCb();
},
}),
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
);
// Active for the whole lifetime of the player screen; cleared on unmount.
useRegisterPlaybackController(playbackController, true);
// Determine play method based on stream URL and media source // Determine play method based on stream URL and media source
const playMethod = useMemo< const playMethod = useMemo<
"DirectPlay" | "DirectStream" | "Transcode" | undefined "DirectPlay" | "DirectStream" | "Transcode" | undefined
@@ -1056,44 +1144,6 @@ export default function DirectPlayerPage() {
} }
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]); }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// TV: Navigate to previous item
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
// Use the live selection, not the stale URL params (see goToNextItem).
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
previousItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// TV: Add subtitle file to player (for client-side downloaded subtitles) // TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback(async (path: string) => { const addSubtitleFile = useCallback(async (path: string) => {
await videoRef.current?.addSubtitleFile?.(path, true); await videoRef.current?.addSubtitleFile?.(path, true);
@@ -1123,45 +1173,25 @@ export default function DirectPlayerPage() {
return []; return [];
}, [isMounted]); }, [isMounted]);
// TV: Navigate to next item /*
const goToNextItem = useCallback(() => { * Item-level navigation (next / previous). Wraps SyncPlay dispatch,
if (!nextItem || !settings || isPlaybackStopped) return; * platform-appropriate local navigation (replace on TV), and offline
* param injection in a single hook so the in-player buttons and any
const { * future entry points (autoplay overlay, episode picker, etc.) share
mediaSource: newMediaSource, * one implementation.
audioIndex: defaultAudioIndex, */
subtitleIndex: defaultSubtitleIndex, const {
} = getDefaultPlaySettings(nextItem, settings, { goToNextItem: dispatchNextItem,
indexes: { goToPreviousItem: dispatchPreviousItem,
// Use the live selection (updated when the user changes tracks } = usePlayerItemNavigation({
// mid-playback), not the stale URL params the episode started with.
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem, nextItem,
settings, previousItem,
currentSubtitleIndex, mediaSource: stream?.mediaSource,
currentAudioIndex, currentAudioIndex,
stream?.mediaSource, currentSubtitleIndex,
bitrateValue, bitrateValue,
router, isDisabled: isPlaybackStopped,
isPlaybackStopped, });
]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
@@ -1308,12 +1338,16 @@ export default function DirectPlayerPage() {
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange} onPictureInPictureChange={_onPictureInPictureChange}
syncPlayDelegated={isSyncPlayEnabled}
onPipPlayRequest={_onPipPlayRequest}
onPipPauseRequest={_onPipPauseRequest}
onPipSkipRequest={_onPipSkipRequest}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.an_error_occurred_while_playing_the_video"), t("player.an_error_occured_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
@@ -1362,8 +1396,8 @@ export default function DirectPlayerPage() {
onSubtitleIndexChange={handleSubtitleIndexChange} onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
goToPreviousItem={goToPreviousItem} goToPreviousItem={dispatchPreviousItem}
goToNextItem={goToNextItem} goToNextItem={dispatchNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks} onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile} addSubtitleFile={addSubtitleFile}
showTechnicalInfo={showTechnicalInfo} showTechnicalInfo={showTechnicalInfo}

View File

@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { SyncPlayProvider } from "@/providers/SyncPlay";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
@@ -409,133 +410,136 @@ function Layout() {
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <LogProvider>
<WebSocketProvider> <WebSocketProvider>
<DownloadProvider> <SyncPlayProvider>
<MusicPlayerProvider> <DownloadProvider>
<GlobalModalProvider> <MusicPlayerProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen {!Platform.isTV && <GlobalModal />}
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "", </GlobalModalProvider>
header: () => null, </MusicPlayerProvider>
}} </DownloadProvider>
/> </SyncPlayProvider>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider> </WebSocketProvider>
</LogProvider> </LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "streamyfin", "name": "streamyfin",

122
components/BitRateSheet.tsx Normal file
View 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>
);
};

View File

@@ -10,31 +10,36 @@ export type Bitrate = {
}; };
export const BITRATES: Bitrate[] = [ export const BITRATES: Bitrate[] = [
{ key: "Max", value: undefined }, {
{ key: "200 Mb/s", value: 200000000 }, key: "Max",
{ key: "180 Mb/s", value: 180000000 }, value: undefined,
{ key: "140 Mb/s", value: 140000000 }, },
{ key: "120 Mb/s", value: 120000000 }, {
{ key: "110 Mb/s", value: 110000000 }, key: "8 Mb/s",
{ key: "100 Mb/s", value: 100000000 }, value: 8000000,
{ key: "90 Mb/s", value: 90000000 }, height: 1080,
{ key: "80 Mb/s", value: 80000000 }, },
{ key: "70 Mb/s", value: 70000000 }, {
{ key: "60 Mb/s", value: 60000000 }, key: "4 Mb/s",
{ key: "50 Mb/s", value: 50000000 }, value: 4000000,
{ key: "40 Mb/s", value: 40000000 }, height: 1080,
{ key: "30 Mb/s", value: 30000000 }, },
{ key: "20 Mb/s", value: 20000000 }, {
{ key: "15 Mb/s", value: 15000000 }, key: "2 Mb/s",
{ key: "10 Mb/s", value: 10000000 }, value: 2000000,
{ key: "8 Mb/s", value: 8000000 }, },
{ key: "5 Mb/s", value: 5000000 }, {
{ key: "4 Mb/s", value: 4000000 }, key: "1 Mb/s",
{ key: "3 Mb/s", value: 3000000 }, value: 1000000,
{ key: "2 Mb/s", value: 2000000 }, },
{ key: "1 Mb/s", value: 1000000 }, {
{ key: "720 Kb/s", value: 720000 }, key: "500 Kb/s",
{ key: "420 Kb/s", value: 420000 }, value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
].sort( ].sort(
(a, b) => (a, b) =>
(b.value || Number.POSITIVE_INFINITY) - (b.value || Number.POSITIVE_INFINITY) -

View File

@@ -1,23 +1,15 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { useCallback, useEffect } from "react";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
CastState,
useCastDevice, useCastDevice,
useCastState,
useDevices, useDevices,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
export function Chromecast({ export function Chromecast({
@@ -26,136 +18,23 @@ export function Chromecast({
background = "transparent", background = "transparent",
...props ...props
}) { }) {
// Hooks called for their side effects (keep Chromecast session active) const client = useRemoteMediaClient();
useRemoteMediaClient(); const castDevice = useCastDevice();
useCastDevice(); const devices = useDevices();
const castState = useCastState(); const sessionManager = GoogleCast.getSessionManager();
useDevices();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Connection menu state
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0);
const lastReportedPlayerStateRef = useRef<string | null>(null);
const playSessionIdRef = useRef<string | null>(null);
const lastContentIdRef = useRef<string | null>(null);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
// Enhanced discovery with retry mechanism - runs once on mount
useEffect(() => { useEffect(() => {
let isSubscribed = true; (async () => {
let retryTimeout: NodeJS.Timeout;
const startDiscoveryWithRetry = async () => {
if (!discoveryManager) { if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return; return;
} }
try { await discoveryManager.startDiscovery();
// Stop any existing discovery first })();
try { }, [client, devices, castDevice, sessionManager, discoveryManager]);
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,
]);
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
@@ -164,92 +43,50 @@ export function Chromecast({
[Platform.OS], [Platform.OS],
); );
// Handle press - show connection menu when connected, otherwise show cast dialog
const handlePress = useCallback(() => {
if (isConnected) {
if (mediaStatus?.currentItemId) {
// Media is playing - navigate to full player
router.push("/casting-player");
} else {
// Connected but no media - show connection menu
setShowConnectionMenu(true);
}
} else {
// Not connected - show cast dialog
CastContext.showCastDialog();
}
}, [isConnected, mediaStatus?.currentItemId]);
// Handle disconnect from Chromecast
const handleDisconnect = useCallback(async () => {
try {
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
} catch (error) {
console.error("[Chromecast] Disconnect error:", error);
}
}, []);
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<> <Pressable
<Pressable className='mr-4' onPress={handlePress} {...props}> className='mr-4'
<AndroidCastButton /> onPress={() => {
<Feather if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
name='cast' else CastContext.showCastDialog();
size={22} }}
color={isConnected ? "#a855f7" : "white"} {...props}
/> >
</Pressable> <AndroidCastButton />
<ChromecastConnectionMenu <Feather name='cast' size={22} color={"white"} />
visible={showConnectionMenu} </Pressable>
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
); );
} }
if (background === "transparent") if (background === "transparent")
return ( return (
<> <RoundButton
<RoundButton size='large'
size='large' className='mr-2'
className='mr-2' background={false}
background={false} onPress={() => {
onPress={handlePress} if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
{...props} else CastContext.showCastDialog();
> }}
<AndroidCastButton /> {...props}
<Feather >
name='cast' <AndroidCastButton />
size={22} <Feather name='cast' size={22} color={"white"} />
color={isConnected ? "#a855f7" : "white"} </RoundButton>
/>
</RoundButton>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
); );
return ( return (
<> <RoundButton
<RoundButton size='large' onPress={handlePress} {...props}> size='large'
<AndroidCastButton /> onPress={() => {
<Feather if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
name='cast' else CastContext.showCastDialog();
size={22} }}
color={isConnected ? "#a855f7" : "white"} {...props}
/> >
</RoundButton> <AndroidCastButton />
<ChromecastConnectionMenu <Feather name='cast' size={22} color={"white"} />
visible={showConnectionMenu} </RoundButton>
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
); );
} }

View File

@@ -6,8 +6,8 @@ import type {
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";

View File

@@ -1,13 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -63,7 +57,6 @@ interface PlatformDropdownProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void; onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: { expoUIConfig?: {
hostStyle?: any; hostStyle?: any;
}; };
@@ -214,31 +207,10 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
onOptionSelect, onOptionSelect,
expoUIConfig, expoUIConfig,
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
disabled: isDisabled,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// @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 // Handle controlled open state for Android
useEffect(() => { useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) { if (Platform.OS === "android" && controlledOpen === true) {
@@ -269,32 +241,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) { if (Platform.OS === "ios" && !Platform.isTV) {
if (isDisabled) { // @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
return ( // the trigger sizes the wrapper while the Host overlays the real Menu.
<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.
return ( return (
<View style={triggerSize ?? { opacity: 0 }}> <View>
{/* Hidden measurer: lays the trigger out off-flow to capture its <View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
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}
>
{trigger} {trigger}
</View> </View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}> <Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
@@ -428,14 +379,8 @@ const PlatformDropdownComponent = ({
}; };
return ( return (
<TouchableOpacity <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
onPress={handlePress} {trigger || <Text className='text-white'>Open Menu</Text>}
activeOpacity={0.7}
disabled={isDisabled}
>
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -8,9 +8,8 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaPlayerState, MediaStreamType,
PlayServicesState, PlayServicesState,
useCastDevice,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
@@ -24,17 +23,20 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { getDownloadedItemById } from "@/providers/Downloads/database"; import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { loadCastMedia } from "@/utils/casting/castLoad"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { chromecast } from "../utils/profiles/chromecast";
import { chromecasth265 } from "../utils/profiles/chromecasth265";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
@@ -56,7 +58,6 @@ export const PlayButton: React.FC<Props> = ({
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation(); const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal(); const { showModal, hideModal } = useGlobalModal();
@@ -65,55 +66,48 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// Single source of truth for all player navigation — SyncPlay,
// offline-vs-stream resolution, and the autoplay counter reset all
// live inside `playItem`.
const { playItem } = usePlayerItemNavigation();
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom; const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors); const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors); const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string) => { (opts: Parameters<typeof playItem>[1]) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) { void playItem(item, opts);
updateSettings({ autoPlayEpisodeCount: 0 });
}
router.push(`/player/direct-player?${q}`);
}, },
[router, isOffline], [item, playItem],
); );
const handleNormalPlayFlow = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
const queryParams = new URLSearchParams({ // Default play options derived from the page's track / source / bitrate
itemId: item.Id!, // pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online
audioIndex: selectedOptions.audioIndex?.toString() ?? "", // routing; we just need to pick a destination (device vs Chromecast).
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", const defaultOpts = {
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", audioIndex: selectedOptions.audioIndex,
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex,
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
offline: isOffline ? "true" : "false", bitrateValue: selectedOptions.bitrate?.value,
}); };
const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString); goToPlayer(defaultOpts);
return; return;
} }
const options = [ const options = ["Chromecast", "Device", "Cancel"];
t("casting_player.chromecast"),
t("casting_player.device"),
t("casting_player.cancel"),
];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
@@ -122,14 +116,9 @@ export const PlayButton: React.FC<Props> = ({
}, },
async (selectedIndex: number | undefined) => { async (selectedIndex: number | undefined) => {
if (!api) return; if (!api) return;
// Compare item IDs AND check if media is actually playing (not stopped/idle) const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const currentContentId = mediaStatus?.mediaInfo?.contentId;
const isMediaActive =
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
const isOpeningCurrentlyPlayingMedia = const isOpeningCurrentlyPlayingMedia =
isMediaActive && currentContentId && currentContentId === item?.Id; currentTitle && currentTitle === item?.Name;
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
@@ -137,8 +126,30 @@ export const PlayButton: React.FC<Props> = ({
if (state && state !== PlayServicesState.SUCCESS) { if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
} else { } else {
if (!api || !user?.Id || !item?.Id) { // Check if user wants H265 for Chromecast
console.warn("Missing parameters for Chromecast streaming"); const enableH265 = settings.enableH265ForChromecast;
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert( Alert.alert(
t("player.client_error"), t("player.client_error"),
t("player.missing_parameters"), t("player.missing_parameters"),
@@ -146,43 +157,116 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
const startPositionMs = // Get a new URL with the Chromecast device profile
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000; try {
const data = await getStreamUrl({
const result = await loadCastMedia({ api,
client, item,
device: castDevice, deviceProfile: enableH265 ? chromecasth265 : chromecast,
api, startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
item, userId: user.Id,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: {
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex, subtitleStreamIndex: selectedOptions.subtitleIndex,
maxBitrate: selectedOptions.bitrate?.value, });
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
startPositionMs,
},
});
if (!result.ok) { console.log("URL: ", data?.url, enableH265);
console.error("[PlayButton] cast load failed:", result.error);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
if (!isOpeningCurrentlyPlayingMedia) { if (!data?.url) {
router.push("/casting-player"); 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);
} }
} }
}); });
break; break;
case 1: case 1:
goToPlayer(queryString); goToPlayer(defaultOpts);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
@@ -192,36 +276,24 @@ export const PlayButton: React.FC<Props> = ({
}, [ }, [
item, item,
client, client,
castDevice,
settings,
api, api,
user, user,
router,
showActionSheetWithOptions, showActionSheetWithOptions,
mediaStatus, mediaStatus,
selectedOptions, selectedOptions,
goToPlayer, goToPlayer,
isOffline,
t, t,
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
lightHapticFeedback();
// Check if item is downloaded // Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly // If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) { if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({ goToPlayer({ forceOffline: true });
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return; return;
} }
@@ -244,13 +316,7 @@ export const PlayButton: React.FC<Props> = ({
<Button <Button
onPress={() => { onPress={() => {
hideModal(); hideModal();
const queryParams = new URLSearchParams({ goToPlayer({ forceOffline: true });
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}} }}
color='purple' color='purple'
> >
@@ -287,13 +353,7 @@ export const PlayButton: React.FC<Props> = ({
{ {
text: t("player.downloaded_file_yes"), text: t("player.downloaded_file_yes"),
onPress: () => { onPress: () => {
const queryParams = new URLSearchParams({ goToPlayer({ forceOffline: true });
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}, },
isPreferred: true, isPreferred: true,
}, },
@@ -317,13 +377,12 @@ export const PlayButton: React.FC<Props> = ({
handleNormalPlayFlow(); handleNormalPlayFlow();
}, [ }, [
item, item,
lightHapticFeedback, isOffline,
handleNormalPlayFlow, handleNormalPlayFlow,
goToPlayer, goToPlayer,
t, t,
showModal, showModal,
hideModal, hideModal,
effectiveColors,
]); ]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { import {
type ChapterEntry, type ChapterEntry,
chapterStartsMs, chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose, onClose,
}: ChapterListProps) { }: ChapterListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null); const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]); const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -79,7 +81,17 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]} supportedOrientations={["portrait", "landscape"]}
> >
<Pressable onPress={onClose} style={styles.backdrop}> <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}> <View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text> <Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable <Pressable
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
backdrop: { backdrop: {
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
}, },
sheet: { sheet: {
backgroundColor: Colors.background, backgroundColor: Colors.background,
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
maxHeight: "70%", maxHeight: "70%",
paddingBottom: 24,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};
};

View File

@@ -60,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return ( return (
<MoreMoviesWithActor <MoreMoviesWithActor
key={`${person.Id}-${idx}`} key={person.Id}
currentItem={item} currentItem={item}
actorId={person.Id} actorId={person.Id}
actorName={person.Name} actorName={person.Name}

View File

@@ -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>
);
}

View File

@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter(); 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 { getDownloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);

View File

@@ -1,62 +1,18 @@
import { Ionicons } from "@expo/vector-icons"; import { Switch, View } from "react-native";
import { useMemo } from "react";
import { View } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
auto: "Automatic (recommended)",
"force-hevc": "Force HEVC / H265",
"force-h264": "Force H264",
};
export const ChromecastSettings: React.FC = ({ ...props }) => { export const ChromecastSettings: React.FC = ({ ...props }) => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const profileOptions = useMemo(
() => [
{
options: (
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
).map((mode) => ({
type: "radio" as const,
label: PROFILE_LABELS[mode],
value: mode,
selected: (settings.chromecastProfile ?? "auto") === mode,
onPress: () => updateSettings({ chromecastProfile: mode }),
})),
},
],
[settings.chromecastProfile, updateSettings],
);
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={"Chromecast"}> <ListGroup title={"Chromecast"}>
<ListItem <ListItem title={"Enable H265 for Chromecast"}>
title={"Profile"} <Switch
subtitle={ value={settings.enableH265ForChromecast}
"Automatic picks codecs per device. Override only if needed." onValueChange={(enableH265ForChromecast) =>
} updateSettings({ enableH265ForChromecast })
>
<PlatformDropdown
groups={profileOptions}
title={"Chromecast profile"}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
} }
/> />
</ListItem> </ListItem>

View File

@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => { export const PlaybackControlsSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -98,48 +96,6 @@ export const PlaybackControlsSettings: React.FC = () => {
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings], [settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
); );
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
// valid selection even if an out-of-range value was stored previously.
const autoplayCountdown = Math.min(
60,
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
);
const castAutoplayCountdown = Math.min(
60,
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
);
const autoplayCountdownOptions = useMemo(
() => [
{
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
type: "radio" as const,
label: String(seconds),
value: String(seconds),
selected: seconds === autoplayCountdown,
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
})),
},
],
[autoplayCountdown, updateSettings],
);
const castAutoplayCountdownOptions = useMemo(
() => [
{
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
type: "radio" as const,
label: String(seconds),
value: String(seconds),
selected: seconds === castAutoplayCountdown,
onPress: () =>
updateSettings({ castAutoplayCountdownSeconds: seconds }),
})),
},
],
[castAutoplayCountdown, updateSettings],
);
const playbackSpeedOptions = useMemo( const playbackSpeedOptions = useMemo(
() => [ () => [
{ {
@@ -295,57 +251,6 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.max_auto_play_episode_count")} title={t("home.settings.other.max_auto_play_episode_count")}
/> />
</ListItem> </ListItem>
{/* Media Segment Skip Settings */}
<ListItem
title={t("home.settings.other.segment_skip_settings")}
subtitle={t("home.settings.other.segment_skip_settings_description")}
onPress={() => router.push("/settings/segment-skip/page")}
>
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
</ListItem>
<ListItem
title={t("home.settings.other.autoplay_countdown_seconds")}
disabled={!settings.autoPlayNextEpisode}
>
<PlatformDropdown
groups={autoplayCountdownOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.autoplay_countdown_seconds")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
disabled={!settings.autoPlayNextEpisode}
>
<PlatformDropdown
groups={castAutoplayCountdownOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{castAutoplayCountdown}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
/>
</ListItem>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );
@@ -366,6 +271,3 @@ const AUTOPLAY_EPISODES_COUNT = (
{ key: "6", value: 6 }, { key: "6", value: 6 },
{ key: "7", value: 7 }, { key: "7", value: 7 },
]; ];
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];

View File

@@ -0,0 +1,259 @@
/**
* GroupSelectionMenu
*
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
* owned by SyncPlayButton). Calls `onClose` after successful actions to
* dismiss the parent sheet.
*/
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
interface GroupSelectionMenuProps {
onClose: () => void;
}
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const {
isEnabled,
groupInfo,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
resumeGroupPlayback,
} = useSyncPlay();
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
const fetchedGroups = await getGroups();
if (!cancelled) {
setGroups(fetchedGroups);
}
} catch (error) {
console.error("Failed to fetch groups", error);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [getGroups]);
const handleJoinGroup = useCallback(
async (groupId: string) => {
try {
await joinGroup(groupId);
onClose();
} catch (error) {
console.error("Failed to join group", error);
}
},
[joinGroup, onClose],
);
const handleCreateGroup = useCallback(async () => {
setIsCreating(true);
try {
await createGroup();
onClose();
} catch (error) {
console.error("Failed to create group", error);
} finally {
setIsCreating(false);
}
}, [createGroup, onClose]);
const handleLeaveGroup = useCallback(async () => {
try {
await leaveGroup();
onClose();
} catch (error) {
console.error("Failed to leave group", error);
}
}, [leaveGroup, onClose]);
// Jump (back) into the group's current item. Mirrors jellyfin-web's
// "Resume playback" menu entry — close the sheet and navigate to
// the player; SyncPlayProvider handles the re-follow + URL build.
const handleResumePlayback = useCallback(async () => {
try {
await resumeGroupPlayback();
onClose();
} catch (error) {
console.error("Failed to resume group playback", error);
}
}, [resumeGroupPlayback, onClose]);
const containerStyle = {
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
paddingTop: 8,
};
if (isEnabled && groupInfo) {
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people' size={24} color='#00a4dc' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
</View>
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
<View className='flex-row items-center justify-between mb-3'>
<Text className='text-neutral-100 font-semibold text-lg'>
{groupInfo.GroupName}
</Text>
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
<Text className='text-white text-xs font-medium'>
{groupInfo.State}
</Text>
</View>
</View>
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
<View className='flex-row items-center'>
<Ionicons name='person' size={16} color='#9ca3af' />
<Text className='text-neutral-400 ml-2'>
{groupInfo.Participants.length} {t("syncplay.members")}
</Text>
</View>
)}
</View>
<View className='mb-3'>
<Button onPress={handleResumePlayback} color='black'>
<View className='flex-row items-center justify-center'>
<Ionicons name='play-circle-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.resume_playback")}
</Text>
</View>
</Button>
</View>
<Button onPress={handleLeaveGroup} color='red'>
<View className='flex-row items-center justify-center'>
<Ionicons name='exit-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.leave_group")}
</Text>
</View>
</Button>
</View>
);
}
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people-outline' size={24} color='white' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
</View>
{isLoading && (
<View className='py-8 items-center'>
<ActivityIndicator color='#00a4dc' />
</View>
)}
{!isLoading && groups.length > 0 && (
<View className='mb-4'>
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
{t("syncplay.available_groups")}
</Text>
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
{groups.map((group, index) => (
<TouchableOpacity
key={group.GroupId ?? index}
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
className={`flex-row items-center p-4 ${
index < groups.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
<Ionicons name='people' size={20} color='#00a4dc' />
</View>
<View className='flex-1'>
<Text className='text-neutral-100 font-medium'>
{group.GroupName}
</Text>
<Text className='text-neutral-500 text-sm'>
{group.Participants?.length ?? 0} {t("syncplay.members")} {" "}
{group.State}
</Text>
</View>
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
</TouchableOpacity>
))}
</View>
</View>
)}
{!isLoading && groups.length === 0 && (
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
<Ionicons name='people-outline' size={40} color='#6b7280' />
<Text className='text-neutral-400 text-center mt-3'>
{t("syncplay.available_groups")}: 0{"\n"}
{t("syncplay.create_new_group")}
</Text>
</View>
)}
{canCreateGroups && (
<Button
onPress={handleCreateGroup}
color='purple'
disabled={isCreating}
>
<View className='flex-row items-center justify-center'>
{isCreating ? (
<ActivityIndicator size='small' color='white' />
) : (
<>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.create_new_group")}
</Text>
</>
)}
</View>
</Button>
)}
</View>
);
}

View File

@@ -0,0 +1,206 @@
/**
* SyncPlayActionIcon
*
* In-button SyncPlay status indicator — drops into the player's
* play/pause button slot and replaces the normal play/pause/loader
* graphic while SyncPlay is mid-transition. Mirrors jellyfin-web's
* `#syncPlayIcon` element (see `showIcon()` in
* `jellyfin-web/src/controllers/playback/video/index.js`).
*
* Icon vocabulary (matched 1:1 with jellyfin-web's `showIcon` switch):
*
* action primary secondary pulse spin
* --------------- ------------- ----------------- ---------- ----
* schedule-play sync play (centered) infinite yes
* unpause play-circle — one-shot no
* pause pause-circle — one-shot no
* seek refresh — infinite no
* buffering clock — infinite no
* wait-pause clock pause (shifted) infinite no
* wait-unpause clock play (shifted) infinite no
*
* Material → Ionicons mapping used here:
* sync → sync, schedule → time-outline, update → refresh-outline,
* play_arrow → play, pause → pause,
* play_circle_outline → play-circle-outline,
* pause_circle_outline → pause-circle-outline.
*
* When no SyncPlay action is active the component renders `fallback`
* so callers can keep the normal play/pause/loader graphic.
*/
import { Ionicons } from "@expo/vector-icons";
import { type ReactNode, useEffect } from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import type { SyncPlayOsdAction } from "@/providers/SyncPlay";
import { useSyncPlay } from "@/providers/SyncPlay";
// SyncPlay cyan (matches jellyfin-web's `.syncPlayIconCircle` color)
const SYNC_PLAY_COLOR = "#00a4dc";
type IoniconName = keyof typeof Ionicons.glyphMap;
type SecondaryPosition = "centered" | "shifted";
interface SecondaryIcon {
icon: IoniconName;
position: SecondaryPosition;
}
interface OsdConfig {
/** Primary icon — fills the available size. */
icon: IoniconName;
/** Optional smaller overlay (~42% size). */
secondary?: SecondaryIcon;
/** Wrapper-level scale animation. */
pulse: "infinite" | "oneshot";
/** Rotate the primary icon continuously (secondary stays still). */
spin?: boolean;
}
const CONFIG: Record<SyncPlayOsdAction, OsdConfig> = {
"schedule-play": {
icon: "sync",
secondary: { icon: "play", position: "centered" },
pulse: "infinite",
spin: true,
},
unpause: { icon: "play-circle-outline", pulse: "oneshot" },
pause: { icon: "pause-circle-outline", pulse: "oneshot" },
seek: { icon: "refresh-outline", pulse: "infinite" },
buffering: { icon: "time-outline", pulse: "infinite" },
"wait-pause": {
icon: "time-outline",
secondary: { icon: "pause", position: "shifted" },
pulse: "infinite",
},
"wait-unpause": {
icon: "time-outline",
secondary: { icon: "play", position: "shifted" },
pulse: "infinite",
},
};
interface SyncPlayActionIconProps {
size: number;
color?: string;
/** Rendered when no SyncPlay action is active. */
fallback?: ReactNode;
}
export function SyncPlayActionIcon({
size,
color = SYNC_PLAY_COLOR,
fallback = null,
}: SyncPlayActionIconProps) {
const { osdAction } = useSyncPlay();
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
useEffect(() => {
cancelAnimation(rotation);
cancelAnimation(scale);
rotation.value = 0;
scale.value = 1;
if (!osdAction) return;
const config = CONFIG[osdAction];
if (config.spin) {
rotation.value = withRepeat(
withTiming(360, { duration: 1200, easing: Easing.linear }),
-1,
false,
);
}
if (config.pulse === "infinite") {
scale.value = withRepeat(
withSequence(
withTiming(1.1, {
duration: 700,
easing: Easing.inOut(Easing.quad),
}),
withTiming(0.95, {
duration: 700,
easing: Easing.inOut(Easing.quad),
}),
),
-1,
true,
);
} else {
// one-shot: single scale flash; the provider clears the action
// ~1500ms later (transient OSD) so the icon then unmounts.
scale.value = withSequence(
withTiming(1.2, { duration: 220, easing: Easing.out(Easing.quad) }),
withTiming(1, { duration: 220, easing: Easing.inOut(Easing.quad) }),
);
}
}, [osdAction, rotation, scale]);
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const spinStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
if (!osdAction) return <>{fallback}</>;
const config = CONFIG[osdAction];
const secondarySize = Math.round(size * 0.42);
// centered: geometric middle of the primary (e.g. play arrow inside
// the spinning `sync` ring for schedule-play).
// shifted: bottom-right corner (e.g. play/pause badge on the clock
// for wait-unpause / wait-pause).
const secondaryPosStyle =
config.secondary?.position === "centered"
? {
top: (size - secondarySize) / 2,
left: (size - secondarySize) / 2,
}
: { bottom: 0, right: 0 };
return (
<Animated.View style={pulseStyle}>
<View style={{ width: size, height: size }}>
<Animated.View style={[StyleSheet.absoluteFill, spinStyle]}>
<Ionicons name={config.icon} size={size} color={color} />
</Animated.View>
{config.secondary && (
<View
pointerEvents='none'
style={[styles.secondary, secondaryPosStyle]}
>
<Ionicons
name={config.secondary.icon}
size={secondarySize}
color={color}
/>
</View>
)}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
secondary: {
position: "absolute",
},
});

View File

@@ -0,0 +1,97 @@
/**
* SyncPlayButton
*
* Header button for accessing SyncPlay functionality.
* Shows group status and opens the group selection sheet.
*
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
* Compose ModalBottomSheet on Android). Because it presents natively, it
* works correctly even when triggered from `headerRight` — no portal or
* provider context is required (unlike @gorhom/bottom-sheet, which fails
* silently from detached UINavigationItem subtrees).
*
* Safe to import statically: this whole module is lazy-required only on
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
*/
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@expo/ui/community/bottom-sheet";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useRef } from "react";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useCastDevice } from "react-native-google-cast";
import { toast } from "sonner-native";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { GroupSelectionMenu } from "./GroupSelectionMenu";
interface SyncPlayButtonProps {
size?: number;
}
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
const { isEnabled, canJoinGroups } = useSyncPlay();
const { isConnected } = useNetworkStatus();
const castDevice = useCastDevice();
const sheetRef = useRef<BottomSheetMethods>(null);
const isCasting = !!castDevice;
const handlePress = useCallback(() => {
if (isCasting) {
toast("SyncPlay not available while casting");
return;
}
sheetRef.current?.present();
}, [isCasting]);
const handleDismiss = useCallback(() => {
sheetRef.current?.dismiss();
}, []);
if (Platform.isTV) return null;
if (!canJoinGroups) return null;
if (!isConnected) return null;
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
return (
<>
<Pressable
className='mr-4'
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<View className='relative'>
<Ionicons
name={isEnabled ? "people" : "people-outline"}
size={size}
color={iconColor}
/>
{isEnabled && !isCasting && (
<View
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
style={{
borderWidth: 1,
borderColor: "#171717",
}}
/>
)}
</View>
</Pressable>
<BottomSheetModal
ref={sheetRef}
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
enablePanDownToClose
>
<BottomSheetView>
<GroupSelectionMenu onClose={handleDismiss} />
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -0,0 +1,53 @@
/**
* SyncPlaySpinner
*
* Compact rotating SyncPlay icon shown in place of the play/pause button
* while a play/pause command is in flight to the server (the "schedule-play"
* indicator from jellyfin-web).
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
// SyncPlay cyan color (matches jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlaySpinnerProps {
size: number;
color?: string;
}
export function SyncPlaySpinner({
size,
color = SYNC_PLAY_COLOR,
}: SyncPlaySpinnerProps) {
const rotation = useSharedValue(0);
useEffect(() => {
rotation.value = withRepeat(
withTiming(360, {
duration: 1200,
easing: Easing.linear,
}),
-1,
false,
);
}, [rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View style={animatedStyle}>
<Ionicons name='sync' size={size} color={color} />
</Animated.View>
);
}

View File

@@ -0,0 +1,8 @@
/**
* SyncPlay UI Components
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
export { SyncPlayButton } from "./SyncPlayButton";
export { SyncPlaySpinner } from "./SyncPlaySpinner";

View File

@@ -1,22 +1,20 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type { import type {
BaseItemDto, BaseItemDto,
ChapterInfo, ChapterInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated"; import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay"; import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble"; import { TrickplayBubble } from "./TrickplayBubble";
@@ -37,14 +35,11 @@ interface BottomControlsProps {
currentTime: number; currentTime: number;
remainingTime: number; remainingTime: number;
showSkipButton: boolean; showSkipButton: boolean;
skipButtonText: string;
showSkipCreditButton: boolean; showSkipCreditButton: boolean;
skipCreditButtonText: string;
hasContentAfterCredits: boolean; hasContentAfterCredits: boolean;
skipIntro: () => void; skipIntro: () => void;
skipCredit: () => void; skipCredit: () => void;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
api?: Api | null;
handleNextEpisodeAutoPlay: () => void; handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void; handleNextEpisodeManual: () => void;
handleControlsInteraction: () => void; handleControlsInteraction: () => void;
@@ -80,9 +75,6 @@ interface BottomControlsProps {
minutes: number; minutes: number;
seconds: number; seconds: number;
}; };
// Chapter props
chapterPositions?: number[];
} }
export const BottomControls: FC<BottomControlsProps> = ({ export const BottomControls: FC<BottomControlsProps> = ({
@@ -95,14 +87,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
currentTime, currentTime,
remainingTime, remainingTime,
showSkipButton, showSkipButton,
skipButtonText,
showSkipCreditButton, showSkipCreditButton,
skipCreditButtonText,
hasContentAfterCredits, hasContentAfterCredits,
skipIntro, skipIntro,
skipCredit, skipCredit,
nextItem, nextItem,
api,
handleNextEpisodeAutoPlay, handleNextEpisodeAutoPlay,
handleNextEpisodeManual, handleNextEpisodeManual,
handleControlsInteraction, handleControlsInteraction,
@@ -119,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
chapterPositions = [],
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false); const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers. // 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; const hasChapters = chapterMarkerList.length > 1;
// Autoplay overlay: shown under the same condition the old countdown button used.
const autoplayAllowed =
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
const showNextEpisodeCountdown =
autoplayAllowed &&
(!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
remainingTime < 10000);
const [secondsRemaining, setSecondsRemaining] = useState(
settings.autoplayCountdownSeconds,
);
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Keep a stable ref to the autoplay handler so the timer effect does not
// restart when the handler identity changes.
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
useEffect(() => {
if (!showNextEpisodeCountdown || autoplayCancelled) {
// Either the show-condition flipped off OR the user cancelled.
// In both cases, stop the running timer immediately so autoplay
// can't fire after Cancel was pressed.
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Only reset cancellation + seconds when the show-condition itself
// flipped off — a fresh credits/end-of-video window then starts a
// brand-new countdown. If we got here because autoplayCancelled
// just flipped true, keep it true so the countdown stays stopped.
if (!showNextEpisodeCountdown) {
setAutoplayCancelled(false);
setSecondsRemaining(settings.autoplayCountdownSeconds);
}
return;
}
setSecondsRemaining(settings.autoplayCountdownSeconds);
intervalRef.current = setInterval(() => {
setSecondsRemaining((prev) => {
if (prev <= 1) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
autoPlayHandlerRef.current();
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [
showNextEpisodeCountdown,
autoplayCancelled,
settings.autoplayCountdownSeconds,
]);
const nextEpisodePosterUrl = useMemo(
() =>
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
[api, nextItem],
);
// Current chapter name for the always-visible header label (live playback). // Current chapter name for the always-visible header label (live playback).
const currentChapterName = useMemo( const currentChapterName = useMemo(
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null), () => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
@@ -231,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
right: right: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, left: insets.left,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, bottom: Math.max(insets.bottom - 17, 0),
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
}, },
]} ]}
className={"flex flex-col px-2"} className={"flex flex-col px-2"}
@@ -273,21 +180,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{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 <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
buttonText={skipButtonText} buttonText='Skip Intro'
/> />
{/* Smart Skip Credits behavior: {/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode - Show "Skip Credits" if there's content after credits OR no next episode
@@ -297,16 +193,34 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem) showSkipCreditButton && (hasContentAfterCredits || !nextItem)
} }
onPress={skipCredit} onPress={skipCredit}
buttonText={skipCreditButtonText} buttonText='Skip Credits'
/> />
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && ( {settings.autoPlayNextEpisode !== false &&
<AutoplayCountdown (settings.maxAutoPlayEpisodeCount.value === -1 ||
nextEpisode={nextItem} settings.autoPlayEpisodeCount <
posterUrl={nextEpisodePosterUrl} settings.maxAutoPlayEpisodeCount.value) && (
secondsRemaining={secondsRemaining} <NextEpisodeCountDownButton
onPlayNow={handleNextEpisodeManual} show={
onCancel={() => setAutoplayCancelled(true)} !nextItem
/> ? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
{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>
</View> </View>

View File

@@ -1,9 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react"; import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +43,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter, goToNextChapter,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
return ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
top: "50%", top: "50%",
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: insets.left,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, right: insets.right,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
@@ -121,15 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}> <View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}> <TouchableOpacity onPress={togglePlay}>
{!isBuffering ? ( <SyncPlayActionIcon
<Ionicons size={ICON_SIZES.CENTER}
name={isPlaying ? "pause" : "play"} fallback={
size={ICON_SIZES.CENTER} !isBuffering ? (
color='white' <Ionicons
/> name={isPlaying ? "pause" : "play"}
) : ( size={ICON_SIZES.CENTER}
<Loader size={"large"} /> color='white'
)} />
) : (
<Loader size={"large"} />
)
}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@@ -7,14 +7,12 @@ import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps { export interface ContinueWatchingOverlayProps {
goToNextItem: (options: { /** Invoked when the user confirms they want to keep watching. */
isAutoPlay: boolean; onContinue: () => void;
resetWatchCount: boolean;
}) => void;
} }
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
goToNextItem, onContinue,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
@@ -29,13 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ? Are you still watching ?
</Text> </Text>
<Button <Button onPress={onContinue} color={"purple"} className='my-4 w-2/3'>
onPress={() => {
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
}}
color={"purple"}
className='my-4 w-2/3'
>
{t("player.continue_watching")} {t("player.continue_watching")}
</Button> </Button>

View File

@@ -4,15 +4,7 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { import { type FC, useCallback, useEffect, useState } from "react";
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, useWindowDimensions, View } from "react-native"; import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -23,18 +15,16 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter"; import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper"; import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { ticksToMs } from "@/utils/time";
import { useSegments } from "@/utils/segments";
import { msToSeconds, ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls"; import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls"; import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants"; import { CONTROLS_CONSTANTS } from "./constants";
@@ -51,9 +41,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector"; import { type AspectRatio } from "./VideoScalingModeSelector";
// No-op function to avoid creating new references on every render
const noop = () => {};
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
isPlaying: boolean; isPlaying: boolean;
@@ -115,31 +102,11 @@ export const Controls: FC<Props> = ({
transcodeReasons, transcodeReasons,
}) => { }) => {
const offline = useOfflineMode(); const offline = useOfflineMode();
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const [episodeView, setEpisodeView] = useState(false); const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false);
// Ref to track pending play timeout for cleanup and cancellation
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
const playingRef = useRef(isPlaying);
useEffect(() => {
playingRef.current = isPlaying;
}, [isPlaying]);
// Clean up timeout on unmount
useEffect(() => {
return () => {
if (playTimeoutRef.current) {
clearTimeout(playTimeoutRef.current);
}
};
}, []);
const { height: screenHeight, width: screenWidth } = useWindowDimensions(); const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = usePlaybackManager({ const { previousItem, nextItem } = usePlaybackManager({
item, item,
@@ -248,7 +215,6 @@ export const Controls: FC<Props> = ({
hasNextChapter, hasNextChapter,
goToPreviousChapter, goToPreviousChapter,
goToNextChapter, goToNextChapter,
chapterPositions,
} = useChapterNavigation({ } = useChapterNavigation({
chapters: item.Chapters, chapters: item.Chapters,
progress, progress,
@@ -346,243 +312,50 @@ export const Controls: FC<Props> = ({
subtitleIndex: string; subtitleIndex: string;
}>(); }>();
// Fetch all segments for the current item const { showSkipButton, skipIntro } = useIntroSkipper(
const { data: segments } = useSegments( item.Id!,
item.Id ?? "", currentTime,
seek,
play,
offline, offline,
downloadedFiles,
api, api,
downloadedFiles,
); );
// Convert milliseconds to seconds for segment comparison const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
const currentTimeSeconds = msToSeconds(currentTime); useCreditSkipper(
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined; item.Id!,
currentTime,
seek,
play,
offline,
api,
downloadedFiles,
maxMs,
);
// Wrapper to convert segment skip from seconds to milliseconds /*
// Includes 200ms delay to allow seek operation to complete before resuming playback * Single source of truth for next / previous / picker / autoplay
const seekMs = useCallback( * navigation. Handles SyncPlay dispatch, autoplay count gating,
(timeInSeconds: number) => { * platform-appropriate local navigation, and offline param injection.
// Cancel any pending play call to avoid race conditions */
if (playTimeoutRef.current) { const {
clearTimeout(playTimeoutRef.current); goToNextItem: handleNextEpisodeManual,
} goToPreviousItem: handlePreviousItem,
seek(timeInSeconds * 1000); goToItem: handleGoToItem,
// Brief delay ensures the seek operation completes before resuming playback handleAutoPlayNext: handleNextEpisodeAutoPlay,
// Without this, playback may resume from the old position handleContinueWatching,
// Read latest isPlaying from ref to avoid stale closure } = usePlayerItemNavigation({
playTimeoutRef.current = setTimeout(() => { nextItem,
if (playingRef.current) { previousItem,
play(); mediaSource,
} currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
playTimeoutRef.current = null; currentSubtitleIndex: subtitleIndex
}, 200); ? Number.parseInt(subtitleIndex, 10)
}, : undefined,
[seek, play], bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined,
);
// Use unified segment skipper for all segment types
const introSkipper = useSegmentSkipper({
segments: segments?.introSegments || [],
segmentType: "Intro",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
}); });
const outroSkipper = useSegmentSkipper({
segments: segments?.creditSegments || [],
segmentType: "Outro",
currentTime: currentTimeSeconds,
totalDuration: maxSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const recapSkipper = useSegmentSkipper({
segments: segments?.recapSegments || [],
segmentType: "Recap",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const commercialSkipper = useSegmentSkipper({
segments: segments?.commercialSegments || [],
segmentType: "Commercial",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const previewSkipper = useSegmentSkipper({
segments: segments?.previewSegments || [],
segmentType: "Preview",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
// Determine which segment button to show (priority order)
// Commercial > Recap > Intro > Preview > Outro
const activeSegment = useMemo(() => {
if (commercialSkipper.currentSegment)
return { type: "Commercial", ...commercialSkipper };
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
if (previewSkipper.currentSegment)
return { type: "Preview", ...previewSkipper };
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
return null;
}, [
commercialSkipper.currentSegment,
recapSkipper.currentSegment,
introSkipper.currentSegment,
previewSkipper.currentSegment,
outroSkipper.currentSegment,
commercialSkipper,
recapSkipper,
introSkipper,
previewSkipper,
outroSkipper,
]);
// Legacy compatibility: map to old variable names
const showSkipButton = !!(
activeSegment &&
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
);
const skipIntro = activeSegment?.skipSegment || noop;
const showSkipCreditButton = activeSegment?.type === "Outro";
const skipCredit = outroSkipper.skipSegment || noop;
const hasContentAfterCredits =
outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds
: false;
// Get button text based on segment type using i18n
const { t } = useTranslation();
const skipButtonText = activeSegment
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
: t("player.skip_intro");
const skipCreditButtonText = t("player.skip_outro");
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) {
return;
}
lightHapticFeedback();
const previousIndexes = {
subtitleIndex: subtitleIndex
? Number.parseInt(subtitleIndex, 10)
: undefined,
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
settings,
{
indexes: previousIndexes,
source: mediaSource ?? undefined,
},
{ applyLanguagePreferences: true },
);
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
return;
}
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(
({
isAutoPlay,
resetWatchCount,
}: {
isAutoPlay?: boolean;
resetWatchCount?: boolean;
}) => {
if (!nextItem) {
return;
}
if (!isAutoPlay) {
// if we are not autoplaying, we won't update anything, we just go to the next item
goToItemCommon(nextItem);
if (resetWatchCount) {
updateSettings({
autoPlayEpisodeCount: 0,
});
}
return;
}
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
if (settings.maxAutoPlayEpisodeCount.value === -1) {
goToItemCommon(nextItem);
return;
}
if (
settings.autoPlayEpisodeCount + 1 <
settings.maxAutoPlayEpisodeCount.value
) {
goToItemCommon(nextItem);
}
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
if (
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
) {
// update the autoPlayEpisodeCount in settings
updateSettings({
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
});
}
},
[nextItem, goToItemCommon],
);
// Add a memoized handler for autoplay next episode
const handleNextEpisodeAutoPlay = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
// Add a memoized handler for manual next episode
const handleNextEpisodeManual = useCallback(() => {
goToNextItem({ isAutoPlay: false });
}, [goToNextItem]);
// Add a memoized handler for ContinueWatchingOverlay
const handleContinueWatching = useCallback(
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
goToNextItem(options);
},
[goToNextItem],
);
const hideControls = useCallback(() => { const hideControls = useCallback(() => {
setShowControls(false); setShowControls(false);
setShowAudioSlider(false); setShowAudioSlider(false);
@@ -610,7 +383,7 @@ export const Controls: FC<Props> = ({
<EpisodeList <EpisodeList
item={item} item={item}
close={() => setEpisodeView(false)} close={() => setEpisodeView(false)}
goToItem={goToItemCommon} goToItem={handleGoToItem}
/> />
) : ( ) : (
<> <>
@@ -644,8 +417,8 @@ export const Controls: FC<Props> = ({
mediaSource={mediaSource} mediaSource={mediaSource}
startPictureInPicture={startPictureInPicture} startPictureInPicture={startPictureInPicture}
switchOnEpisodeMode={switchOnEpisodeMode} switchOnEpisodeMode={switchOnEpisodeMode}
goToPreviousItem={goToPreviousItem} goToPreviousItem={handlePreviousItem}
goToNextItem={goToNextItem} goToNextItem={handleNextEpisodeManual}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
@@ -691,14 +464,11 @@ export const Controls: FC<Props> = ({
currentTime={currentTime} currentTime={currentTime}
remainingTime={remainingTime} remainingTime={remainingTime}
showSkipButton={showSkipButton} showSkipButton={showSkipButton}
skipButtonText={skipButtonText}
showSkipCreditButton={showSkipCreditButton} showSkipCreditButton={showSkipCreditButton}
skipCreditButtonText={skipCreditButtonText}
hasContentAfterCredits={hasContentAfterCredits} hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro} skipIntro={skipIntro}
skipCredit={skipCredit} skipCredit={skipCredit}
nextItem={nextItem} nextItem={nextItem}
api={api}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay} handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual} handleNextEpisodeManual={handleNextEpisodeManual}
handleControlsInteraction={handleControlsInteraction} handleControlsInteraction={handleControlsInteraction}
@@ -715,13 +485,12 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/> />
</Animated.View> </Animated.View>
</> </>
)} )}
{settings.maxAutoPlayEpisodeCount.value !== -1 && ( {settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} /> <ContinueWatchingOverlay onContinue={handleContinueWatching} />
)} )}
</View> </View>
); );

View File

@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -17,10 +16,10 @@ import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { import {
getDownloadedEpisodesForSeason, getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers, getDownloadedSeasonNumbers,
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { settings } = useSettings(); const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
// Set the initial season index // Set the initial season index
useEffect(() => { 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 { getDownloadedItems } = useDownload();
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black", backgroundColor: "black",
height: "100%", height: "100%",
width: "100%", width: "100%",
paddingTop: paddingTop: insets.top,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, paddingLeft: insets.left,
paddingLeft: paddingRight: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}} }}
> >
<View <View

View File

@@ -5,12 +5,11 @@ import type {
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -25,7 +24,7 @@ interface HeaderControlsProps {
startPictureInPicture?: () => Promise<void>; startPictureInPicture?: () => Promise<void>;
switchOnEpisodeMode: () => void; switchOnEpisodeMode: () => void;
goToPreviousItem: () => void; goToPreviousItem: () => void;
goToNextItem: (options: { isAutoPlay?: boolean }) => void; goToNextItem: () => void;
previousItem?: BaseItemDto | null; previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
aspectRatio?: AspectRatio; aspectRatio?: AspectRatio;
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation(); const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false); const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, top: insets.top,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: insets.left,
right: right: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING, padding: HEADER_LAYOUT.CONTAINER_PADDING,
}, },
]} ]}
@@ -175,7 +172,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
)} )}
{nextItem && ( {nextItem && (
<TouchableOpacity <TouchableOpacity
onPress={() => goToNextItem({ isAutoPlay: false })} onPress={() => goToNextItem()}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
> >
<Ionicons <Ionicons

View File

@@ -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;

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants"; import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0); const opacity = useSharedValue(0);
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20, left: Math.max(insets.left, 48) + 20,
} }
: { : {
top: top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
(settings?.safeAreaInControlsEnabled ?? true) left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
? 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,
}; };
const textStyle = Platform.isTV const textStyle = Platform.isTV

View File

@@ -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: ___________

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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,
};
}

View File

@@ -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 };
};

View File

@@ -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,
};
};

View 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;
};

View File

@@ -1,3 +1,4 @@
import { File, Paths } from "expo-file-system";
import { useCallback } from "react"; import { useCallback } from "react";
import { storage } from "@/utils/mmkv"; 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) => { const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return 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 { try {
// Fetch the data from the URL const downloaded = await File.downloadFileAsync(url, tmpFile, {
const response = await fetch(url); idempotent: true,
blob = await response.blob(); });
return await downloaded.base64();
} catch (error) { } catch (error) {
console.warn("Error fetching image:", error); console.warn("Error fetching image:", error);
return null; 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( const saveImage = useCallback(

View File

@@ -0,0 +1,22 @@
import { useEffect } from "react";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
/**
* While `active` is true, hold a keep-alive token on the global
* WebSocket so it is NOT closed when the app moves to
* background/inactive. Releases automatically when `active` flips
* false or the component unmounts.
*
* Used by the video player while in Picture-in-Picture so SyncPlay
* commands (and any other server pushes) keep flowing while the OS
* thinks the app is backgrounded.
*/
export function useKeepWebSocketAlive(active: boolean): void {
const { acquireKeepAlive } = useWebSocketContext();
useEffect(() => {
if (!active) return;
const release = acquireKeepAlive();
return release;
}, [active, acquireKeepAlive]);
}

View File

@@ -0,0 +1,405 @@
/**
* Single source of truth for *all* item-level navigation inside the
* player (next / previous / picked-from-episode-list / autoplay-next).
*
* This hook encapsulates three orthogonal concerns so callers don't
* have to:
*
* 1. **SyncPlay** — when a group is active, every advance/rewind
* dispatches through `Controller`. `SyncPlayProvider` handles the
* resulting `localPlay` / `localSetCurrentPlaylistItem` events and
* navigates the local screen.
* 2. **Autoplay gating** — `maxAutoPlayEpisodeCount` limits how many
* episodes auto-play before stopping. Manual presses bypass this.
* SyncPlay bypasses it too (the server drives the queue).
* 3. **Platform navigation** — mobile uses `router.setParams` so the
* player view stays mounted (avoids a full re-mount + bitrate /
* stream re-pick cycle). TV uses `router.replace` because MPV's
* native view can't be re-initialized in place. Offline state is
* preserved automatically by `useAppRouter`.
*/
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform } from "react-native";
import useAppRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { getDownloadedItemById } from "@/providers/Downloads";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface UsePlayerItemNavigationParams {
/**
* The adjacent item that "next" should target (from `usePlaybackManager`).
* Only needed by callers that use the in-session nav methods.
*/
nextItem?: BaseItemDto | null;
/** The adjacent item that "previous" should target. */
previousItem?: BaseItemDto | null;
/** The active media source for the *current* item; used to seed track defaults. */
mediaSource?: MediaSourceInfo | null;
/** Live audio track index (may differ from the URL param after the user changed tracks). */
currentAudioIndex?: number;
/** Live subtitle track index. */
currentSubtitleIndex?: number;
/** Currently-active bitrate cap. */
bitrateValue?: number;
/**
* Optional guard for "we're already stopping the player". TV passes
* `isPlaybackStopped` here to drop spurious next-item dispatches that
* fire during teardown.
*/
isDisabled?: boolean;
}
/** Options for `playItem` — the entry-point method used by PlayButton et al. */
export interface PlayItemOptions {
audioIndex?: number;
subtitleIndex?: number;
mediaSourceId?: string;
bitrateValue?: number;
/** Defaults to `item.UserData?.PlaybackPositionTicks`. */
playbackPosition?: number;
/**
* Force local-file playback even outside the offline UI context, and
* skip SyncPlay broadcasting. Used when the user explicitly picks the
* downloaded copy from a "play downloaded?" prompt.
*/
forceOffline?: boolean;
}
export interface PlayerItemNavigation {
/** SyncPlay-aware previous. No-op when there's no previous item. */
goToPreviousItem: () => void;
/**
* Manual next (e.g. user tapped the skip-forward / next button).
* Autoplay gating is bypassed.
*/
goToNextItem: () => void;
/** Jump to an arbitrary item (episode picker). */
goToItem: (item: BaseItemDto) => void;
/**
* Autoplay next (e.g. "Up Next" overlay countdown completed). Respects
* `maxAutoPlayEpisodeCount`. Bypassed when SyncPlay is active.
*/
handleAutoPlayNext: () => void;
/**
* Helper for the "Keep Watching" overlay button — advances and resets
* the auto-play counter so the next stretch of episodes can autoplay.
*/
handleContinueWatching: () => void;
/**
* Entry-point: start playback of an item from outside the player
* (PlayButton, Continue Watching, episode picker on the item page).
* SyncPlay-aware. Resets the autoplay counter.
*/
playItem: (item: BaseItemDto, opts?: PlayItemOptions) => Promise<void>;
}
export function usePlayerItemNavigation(
params: UsePlayerItemNavigationParams = {},
): PlayerItemNavigation {
const {
nextItem,
previousItem,
mediaSource,
currentAudioIndex,
currentSubtitleIndex,
bitrateValue,
isDisabled,
} = params;
const router = useAppRouter();
const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
const lightHapticFeedback = useHaptic("light");
// Note: "offline mode" is a *UI context* flag (set by pages entered from
// the downloads tab), not a network-connectivity status. A user may be
// in offline mode with perfect internet, watching a downloaded copy.
const inOfflineContext = useOfflineMode();
/*
* Compute the destination URL params for a given target item, using
* the live selection state (which may differ from the URL params the
* episode started with — the user may have switched tracks mid-play).
*/
const buildNavigationParams = useCallback(
(target: BaseItemDto) => {
if (!settings) return null;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
target,
settings,
{
indexes: {
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: mediaSource ?? undefined,
},
{ applyLanguagePreferences: true },
);
return {
itemId: target.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
target.UserData?.PlaybackPositionTicks?.toString() ?? "",
};
},
[
settings,
currentSubtitleIndex,
currentAudioIndex,
mediaSource,
bitrateValue,
],
);
/*
* Stamp the `offline` URL param onto a params object based on whether
* the target item is actually downloaded.
*
* - Online (no offline UI context) → pass through unchanged; the
* `offline` key is absent so `useAppRouter` doesn't touch it.
* - Offline context, target IS downloaded → `offline: "true"` (play
* the local copy).
* - Offline context, target is NOT downloaded → `offline: ""` (force
* online streaming; the key's presence blocks `useAppRouter` from
* auto-injecting `"true"`, and direct-player only treats the value
* `"true"` as offline).
*
* That last case is the important one: a user can be in the offline
* UI context with perfect internet (e.g. navigated in from downloads)
* and pick an episode they never downloaded. Without this the player
* would hang waiting for a local file that doesn't exist.
*/
const withOfflineParam = useCallback(
(params: Record<string, string>, target: BaseItemDto) => {
if (!inOfflineContext) return params;
const isDownloaded = !!(target.Id && getDownloadedItemById(target.Id));
return { ...params, offline: isDownloaded ? "true" : "" };
},
[inOfflineContext],
);
/*
* Platform-appropriate local navigation. Mobile keeps the same player
* view mounted via setParams; TV swaps the whole route via replace.
*/
const localNavigate = useCallback(
(target: BaseItemDto) => {
if (isDisabled) return;
const navParams = buildNavigationParams(target);
if (!navParams) return;
lightHapticFeedback();
const finalParams = withOfflineParam(navParams, target);
if (Platform.isTV) {
const queryString = new URLSearchParams(finalParams).toString();
router.replace(`/player/direct-player?${queryString}`);
} else {
router.setParams(finalParams);
}
},
[
isDisabled,
buildNavigationParams,
router,
lightHapticFeedback,
withOfflineParam,
],
);
const goToPreviousItem = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.previousItem();
return;
}
if (!previousItem) return;
localNavigate(previousItem);
}, [isSyncPlayEnabled, syncPlayController, previousItem, localNavigate]);
const goToNextItem = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
localNavigate(nextItem);
}, [isSyncPlayEnabled, syncPlayController, nextItem, localNavigate]);
const goToItem = useCallback(
(target: BaseItemDto) => {
if (isSyncPlayEnabled && syncPlayController && target.Id) {
syncPlayController.goToItem(target);
return;
}
localNavigate(target);
},
[isSyncPlayEnabled, syncPlayController, localNavigate],
);
const handleAutoPlayNext = useCallback(() => {
// SyncPlay always advances unconditionally — the server is the source
// of truth for queue progression and per-client gating would desync us.
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
const maxCount = settings?.maxAutoPlayEpisodeCount.value ?? 0;
const currentCount = settings?.autoPlayEpisodeCount ?? 0;
// -1 means "no limit"
if (maxCount === -1) {
localNavigate(nextItem);
return;
}
if (currentCount + 1 < maxCount) {
localNavigate(nextItem);
}
if (currentCount < maxCount) {
updateSettings({ autoPlayEpisodeCount: currentCount + 1 });
}
}, [
isSyncPlayEnabled,
syncPlayController,
nextItem,
settings,
updateSettings,
localNavigate,
]);
const handleContinueWatching = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
updateSettings({ autoPlayEpisodeCount: 0 });
localNavigate(nextItem);
}, [
isSyncPlayEnabled,
syncPlayController,
nextItem,
updateSettings,
localNavigate,
]);
/*
* Entry-point: start playback of an item from outside the player.
*
* Used by PlayButton, Continue Watching cards, episode pickers on the
* item page, etc. Unlike the in-session methods, this always uses
* `router.push` (we're entering the player, not navigating within it)
* and runs on both mobile and TV with the same shape.
*
* SyncPlay: when in a group and the user *didn't* explicitly request
* local playback, we route through `controller.play()` so every group
* member gets the same `PlayQueue: NewPlaylist` update and navigates
* together. Errors surface as an Alert and abort the local navigation
* (matches `PlayButton`'s previous behavior).
*/
const playItem = useCallback(
async (item: BaseItemDto, opts: PlayItemOptions = {}) => {
if (!item.Id) return;
lightHapticFeedback();
const startPositionTicks =
opts.playbackPosition ?? item.UserData?.PlaybackPositionTicks ?? 0;
// Fresh playback start — reset the autoplay budget so the next
// stretch of episodes can autoplay.
if (settings && settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
// SyncPlay: broadcast to the group instead of navigating locally.
// Skipped when the user explicitly picked the downloaded copy — a
// local file can't be part of a synced session.
if (!opts.forceOffline && isSyncPlayEnabled && syncPlayController) {
try {
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
// Build a string-record so we can run it through `withOfflineParam`
// and `URLSearchParams` uniformly.
const baseParams: Record<string, string> = {
itemId: item.Id,
playbackPosition: String(startPositionTicks),
};
if (opts.audioIndex !== undefined) {
baseParams.audioIndex = String(opts.audioIndex);
}
if (opts.subtitleIndex !== undefined) {
baseParams.subtitleIndex = String(opts.subtitleIndex);
}
if (opts.mediaSourceId) {
baseParams.mediaSourceId = opts.mediaSourceId;
}
if (opts.bitrateValue !== undefined) {
baseParams.bitrateValue = String(opts.bitrateValue);
}
const finalParams = opts.forceOffline
? { ...baseParams, offline: "true" }
: withOfflineParam(baseParams, item);
const queryString = new URLSearchParams(finalParams).toString();
router.push(`/player/direct-player?${queryString}`);
},
[
lightHapticFeedback,
settings,
updateSettings,
isSyncPlayEnabled,
syncPlayController,
withOfflineParam,
router,
t,
],
);
return {
goToPreviousItem,
goToNextItem,
goToItem,
handleAutoPlayNext,
handleContinueWatching,
playItem,
};
}
export default usePlayerItemNavigation;

View File

@@ -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]);
};

View File

@@ -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,
};
};

View File

@@ -17,24 +17,20 @@ interface TrickplayUrl {
} }
/** Hook to handle trickplay logic for a given item. */ /** Hook to handle trickplay logic for a given item. */
export const useTrickplay = (item: BaseItemDto | null) => { export const useTrickplay = (item: BaseItemDto) => {
const { getDownloadedItemById } = useDownload(); const { getDownloadedItemById } = useDownload();
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null); const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0); const lastCalculationTime = useRef(0);
const throttleDelay = 200; const throttleDelay = 200;
const isOffline = useGlobalSearchParams().offline === "true"; const isOffline = useGlobalSearchParams().offline === "true";
const trickplayInfo = useMemo( const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
() => (item ? getTrickplayInfo(item) : null),
[item],
);
/** Generates the trickplay URL for the given item and sheet index. /** Generates the trickplay URL for the given item and sheet index.
* We change between offline and online trickplay URLs depending on the state of the app. */ * We change between offline and online trickplay URLs depending on the state of the app. */
const getTrickplayUrl = useCallback( const getTrickplayUrl = useCallback(
(item: BaseItemDto, sheetIndex: number) => { (item: BaseItemDto, sheetIndex: number) => {
if (!item.Id) return null;
// If we are offline, we can use the downloaded item's trickplay data path // If we are offline, we can use the downloaded item's trickplay data path
const downloadedItem = getDownloadedItemById(item.Id); const downloadedItem = getDownloadedItemById(item.Id!);
if (isOffline && downloadedItem?.trickPlayData?.path) { if (isOffline && downloadedItem?.trickPlayData?.path) {
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`; return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
} }
@@ -49,7 +45,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
const now = Date.now(); const now = Date.now();
if ( if (
!trickplayInfo || !trickplayInfo ||
!item?.Id || !item.Id ||
now - lastCalculationTime.current < throttleDelay now - lastCalculationTime.current < throttleDelay
) )
return; return;
@@ -66,7 +62,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */ /** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
const prefetchAllTrickplayImages = useCallback(async () => { const prefetchAllTrickplayImages = useCallback(async () => {
if (!trickplayInfo || !item?.Id) return; if (!trickplayInfo || !item.Id) return;
const maxConcurrent = 4; const maxConcurrent = 4;
const total = trickplayInfo.totalImageSheets; const total = trickplayInfo.totalImageSheets;
const urls: string[] = []; const urls: string[] = [];

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert } from "react-native"; import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps { interface UseWebSocketProps {
@@ -80,9 +81,9 @@ export const useWebSocket = ({
playTrailers, playTrailers,
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const { lastMessage } = useWebSocketContext(); const { lastMessage, clearLastMessage } = useWebSocketContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { clearLastMessage } = useWebSocketContext(); const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
useEffect(() => { useEffect(() => {
if (!lastMessage) return; if (!lastMessage) return;
@@ -96,6 +97,25 @@ export const useWebSocket = ({
| Record<string, string> | Record<string, string>
| undefined; // Arguments are Dictionary<string, string> | undefined; // Arguments are Dictionary<string, string>
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
const isSyncPlayCommand =
lastMessage.MessageType === "SyncPlayCommand" ||
lastMessage.MessageType === "SyncPlayGroupUpdate";
const isPlaybackCommand = [
"PlayPause",
"Pause",
"Unpause",
"Stop",
"Seek",
"NextTrack",
"PreviousTrack",
].includes(command ?? "");
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
return;
}
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");
togglePlay(); togglePlay();

View File

@@ -50,6 +50,13 @@ class MpvPlayerModule : Module() {
// No-op on Android - media session integration would require MediaSessionCompat // No-op on Android - media session integration would require MediaSessionCompat
} }
// When true, PiP play/pause/skip controls emit JS events
// instead of driving MPV directly, so the host app can route
// through SyncPlay (server -> group broadcast -> all clients).
Prop("syncPlayDelegated") { view: MpvPlayerView, delegated: Boolean ->
view.syncPlayDelegated = delegated
}
// Async function to play video // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()
@@ -198,7 +205,7 @@ class MpvPlayerModule : Module() {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
} }
} }
} }

View File

@@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onError by EventDispatcher() val onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher() val onPictureInPictureChange by EventDispatcher()
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
// (play / pause / skip) emit these events instead of driving MPV
// directly, so JS can route the action through the SyncPlay
// controller (server -> group broadcast -> all clients). Default
// behavior (non-SyncPlay) is unchanged.
val onPipPlayRequest by EventDispatcher()
val onPipPauseRequest by EventDispatcher()
val onPipSkipRequest by EventDispatcher()
var syncPlayDelegated: Boolean = false
private var textureView: TextureView private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlayerView(textureView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
if (syncPlayDelegated) {
onPipPlayRequest(mapOf<String, Any>())
return
}
play() play()
} }
override fun onPause() { override fun onPause() {
if (syncPlayDelegated) {
onPipPauseRequest(mapOf<String, Any>())
return
}
pause() pause()
} }
override fun onSeekBy(seconds: Double) { override fun onSeekBy(seconds: Double) {
if (syncPlayDelegated) {
val target = (cachedPosition + seconds).coerceAtLeast(0.0)
onPipSkipRequest(
mapOf(
"targetSeconds" to target,
"intervalSeconds" to seconds
)
)
return
}
seekBy(seconds) seekBy(seconds)
} }

View File

@@ -65,6 +65,13 @@ public class MpvPlayerModule: Module {
} }
} }
// When true, PiP play/pause/skip controls emit JS events instead
// of driving MPV directly, so the host app can route through
// SyncPlay (server -> group broadcast -> all clients).
Prop("syncPlayDelegated") { (view: MpvPlayerView, delegated: Bool) in
view.syncPlayDelegated = delegated
}
// Async function to play video // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()
@@ -213,7 +220,7 @@ public class MpvPlayerModule: Module {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
} }
} }
} }

View File

@@ -61,6 +61,17 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
let onError = EventDispatcher() let onError = EventDispatcher()
let onTracksReady = EventDispatcher() let onTracksReady = EventDispatcher()
let onPictureInPictureChange = EventDispatcher()
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
// (play / pause / skip) emit these events instead of driving MPV
// directly, so JS can route the action through the SyncPlay
// controller (server group broadcast all clients). Default
// behavior (non-SyncPlay) is unchanged.
let onPipPlayRequest = EventDispatcher()
let onPipPauseRequest = EventDispatcher()
let onPipSkipRequest = EventDispatcher()
var syncPlayDelegated: Bool = false
private var currentURL: URL? private var currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -81,7 +92,6 @@ class MpvPlayerView: ExpoView {
private func setupView() { private func setupView() {
clipsToBounds = true clipsToBounds = true
backgroundColor = .black backgroundColor = .black
configureAudioSession()
videoContainer = UIView() videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -141,21 +151,26 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func configureAudioSession() { private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
do { do {
try audioSession.setCategory( try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
.playback, try session.setActive(true)
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch { } catch {
print("Failed to configure audio session: \(error)") 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() { private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio) // Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -270,6 +285,7 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
configureAudioSession()
setupRemoteCommands() setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -440,6 +456,7 @@ class MpvPlayerView: ExpoView {
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo() clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }
@@ -519,9 +536,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { 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)), syncing Now Playing")
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused()) syncNowPlaying(isPlaying: !isPaused())
} }
} }
@@ -633,6 +648,9 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)") print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts // Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) 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) { func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -651,6 +669,9 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill { if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill 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) { func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
@@ -660,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPlay(_ controller: PiPController) { func pipControllerPlay(_ controller: PiPController) {
print("PiP play requested") print("PiP play requested")
if syncPlayDelegated {
// Let JS route through SyncPlay. We deliberately do NOT touch
// MPV here; the WS command coming back will drive playback.
onPipPlayRequest([:])
return
}
intendedPlayState = true intendedPlayState = true
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -667,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPause(_ controller: PiPController) { func pipControllerPause(_ controller: PiPController) {
print("PiP pause requested") print("PiP pause requested")
if syncPlayDelegated {
onPipPauseRequest([:])
return
}
intendedPlayState = false intendedPlayState = false
renderer?.pausePlayback() renderer?.pausePlayback()
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
@@ -676,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
let seconds = CMTimeGetSeconds(interval) let seconds = CMTimeGetSeconds(interval)
print("PiP skip by interval: \(seconds)") print("PiP skip by interval: \(seconds)")
let target = max(0, cachedPosition + seconds) let target = max(0, cachedPosition + seconds)
if syncPlayDelegated {
// `targetSeconds` lets JS convert to ticks and call
// syncPlayController.seek(). `intervalSeconds` is also sent
// for telemetry / debug.
onPipSkipRequest([
"targetSeconds": target,
"intervalSeconds": seconds
])
return
}
seekTo(position: target) seekTo(position: target)
} }

View File

@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
isActive: boolean; isActive: boolean;
}; };
/**
* Emitted when the user taps a PiP playback control while the view
* was rendered with `syncPlayDelegated`. The host app should route
* the action through the SyncPlay controller instead of acting
* locally.
*/
export type OnPipPlayRequestPayload = Record<string, never>;
export type OnPipPauseRequestPayload = Record<string, never>;
export type OnPipSkipRequestPayload = {
/** Absolute target position the user wants to seek to, in seconds. */
targetSeconds: number;
/** Skip interval requested by the OS (signed seconds). Debug only. */
intervalSeconds: number;
};
export type NowPlayingMetadata = { export type NowPlayingMetadata = {
title?: string; title?: string;
artist?: string; artist?: string;
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
onPictureInPictureChange?: (event: { onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload; nativeEvent: OnPictureInPictureChangePayload;
}) => void; }) => void;
/**
* When true, PiP play/pause/skip controls emit the corresponding
* `onPipPlayRequest` / `onPipPauseRequest` / `onPipSkipRequest`
* events instead of driving MPV directly. Used to route PiP control
* actions through SyncPlay.
*/
syncPlayDelegated?: boolean;
onPipPlayRequest?: (event: { nativeEvent: OnPipPlayRequestPayload }) => void;
onPipPauseRequest?: (event: {
nativeEvent: OnPipPauseRequestPayload;
}) => void;
onPipSkipRequest?: (event: { nativeEvent: OnPipSkipRequestPayload }) => void;
}; };
export interface MpvPlayerViewRef { export interface MpvPlayerViewRef {

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; 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 * 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 { 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); const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) { 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 * Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/ */
export function saveDownloadsDatabase(db: DownloadsDatabase): void { export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); 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 * Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/ */
export function getAllDownloadedItems(): DownloadedItem[] { 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 db = getDownloadsDatabase();
const items: DownloadedItem[] = []; const items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
} }
} }
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items; 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 { function ensureItemIndex(): void {
const db = getDownloadsDatabase(); if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
if (db.movies[id]) {
return db.movies[id];
} }
for (const series of Object.values(db.series)) { // Build new index from all items
for (const season of Object.values(series.seasons)) { itemIndex = new Map<string, DownloadedItem>();
for (const episode of Object.values(season.episodes)) { const items = getAllDownloadedItems();
if (episode.item.Id === id) {
return episode; for (const item of items) {
} if (item.item.Id) {
} itemIndex.set(item.item.Id, item);
} }
} }
if (db.other?.[id]) { indexCacheVersion = cacheVersion;
return db.other[id]; }
}
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 { export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
} }

View File

@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
text: string; text: string;
} }
export interface Segment {
startTime: number;
endTime: number;
text: string;
}
/** Represents a single downloaded media item with all necessary metadata for offline playback. */ /** Represents a single downloaded media item with all necessary metadata for offline playback. */
export interface DownloadedItem { export interface DownloadedItem {
/** The Jellyfin item DTO. */ /** The Jellyfin item DTO. */
@@ -50,12 +56,6 @@ export interface DownloadedItem {
introSegments?: MediaTimeSegment[]; introSegments?: MediaTimeSegment[];
/** The credit segments for the item. */ /** The credit segments for the item. */
creditSegments?: MediaTimeSegment[]; creditSegments?: MediaTimeSegment[];
/** The recap segments for the item. */
recapSegments?: MediaTimeSegment[];
/** The commercial segments for the item. */
commercialSegments?: MediaTimeSegment[];
/** The preview segments for the item. */
previewSegments?: MediaTimeSegment[];
/** The user data for the item. */ /** The user data for the item. */
userData: UserData; userData: UserData;
} }
@@ -144,12 +144,6 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[]; introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */ /** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[]; creditSegments?: MediaTimeSegment[];
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
recapSegments?: MediaTimeSegment[];
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
commercialSegments?: MediaTimeSegment[];
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
previewSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */ /** The audio stream index selected for this download */
audioStreamIndex?: number; audioStreamIndex?: number;
/** The subtitle stream index selected for this download */ /** The subtitle stream index selected for this download */

View File

@@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
const response = await getUserApi(apiInstance).getCurrentUser(); // Dismiss splash screen with cached data immediately,
setUser(response.data); // fetch fresh user data in the background
setInitialLoaded(true);
// Migrate current session to secure storage if not already saved try {
if (storedUser?.Id && storedUser?.Name) { const response = await getUserApi(apiInstance).getCurrentUser();
const existingCredential = await getAccountCredential( setUser(response.data);
serverUrl,
storedUser.Id, // Migrate current session to secure storage if not already saved
); if (storedUser?.Id && storedUser?.Name) {
if (!existingCredential) { const existingCredential = await getAccountCredential(
await saveAccountCredential({
serverUrl, serverUrl,
serverName: "", storedUser.Id,
token, );
userId: storedUser.Id, if (!existingCredential) {
username: storedUser.Name, await saveAccountCredential({
savedAt: Date.now(), serverUrl,
securityType: "none", serverName: "",
primaryImageTag: response.data.PrimaryImageTag ?? undefined, token,
}); userId: storedUser.Id,
} else if ( username: storedUser.Name,
response.data.PrimaryImageTag !== savedAt: Date.now(),
existingCredential.primaryImageTag securityType: "none",
) { primaryImageTag: response.data.PrimaryImageTag ?? undefined,
// Update image tag if it has changed });
addAccountToServer(serverUrl, existingCredential.serverName, { } else if (
userId: existingCredential.userId, response.data.PrimaryImageTag !==
username: existingCredential.username, existingCredential.primaryImageTag
securityType: existingCredential.securityType, ) {
savedAt: existingCredential.savedAt, // Update image tag if it has changed
primaryImageTag: response.data.PrimaryImageTag ?? undefined, 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) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };

View File

@@ -28,10 +28,6 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { settingsAtom } from "@/utils/atoms/settings"; import { settingsAtom } from "@/utils/atoms/settings";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
// Conditionally import TrackPlayer only on non-TV platforms // Conditionally import TrackPlayer only on non-TV platforms
// This prevents the native module from being loaded on TV where it doesn't exist // This prevents the native module from being loaded on TV where it doesn't exist
@@ -1625,43 +1621,6 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
settings?.audioLookaheadCount, settings?.audioLookaheadCount,
]); ]);
// App-wide remote-control surface: wraps the existing music controls so
// remote commands can target whatever player is currently active.
const isMusicActive = state.currentTrack !== null;
const playbackController = useMemo<PlaybackController>(
() => ({
playPause: () => {
togglePlayPause();
},
pause: () => {
pause();
},
unpause: () => {
resume();
},
stop: () => {
stop();
},
// TrackPlayer works in seconds; the controller contract is milliseconds.
seek: (positionMs: number) => {
seek(positionMs / 1000);
},
next: () => {
next();
},
previous: () => {
previous();
},
// The music player exposes no volume API — keep these as no-ops.
setVolume: () => {},
toggleMute: () => {},
}),
[togglePlayPause, pause, resume, stop, seek, next, previous],
);
useRegisterPlaybackController(playbackController, isMusicActive);
const value = useMemo( const value = useMemo(
() => ({ () => ({
...state, ...state,

View File

@@ -0,0 +1,191 @@
/**
* SyncPlay Controller — public playback API exposed to consumers.
*
* Methods are fire-and-forget by design: SyncPlay HTTP responses don't
* carry useful info (the real state arrives via WebSocket broadcast).
* Wrap calls in try/catch so transient network errors don't reach the UI.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import type { SyncPlayManager } from "./Manager";
import {
getItemsForPlayback,
type TranslateOptions,
translateItemsForPlayback,
} from "./transport/queueTranslation";
export interface PlayOptions extends TranslateOptions {
items?: BaseItemDto[];
ids?: string[];
startIndex?: number;
startPositionTicks?: number;
}
export class Controller {
private manager!: SyncPlayManager;
init(manager: SyncPlayManager): void {
this.manager = manager;
}
/** Toggle play/pause for the whole group. */
playPause(): void {
if (this.manager.isPlaying()) {
this.pause();
} else {
this.unpause();
}
}
/** Resume the group's playback. */
unpause(): void {
this.manager.markPendingPlaybackCommand("Unpause");
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause();
} catch (error) {
console.error("SyncPlay Controller.unpause failed", error);
}
}
/** Pause the group's playback. */
pause(): void {
this.manager.markPendingPlaybackCommand("Pause");
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayPause();
} catch (error) {
console.error("SyncPlay Controller.pause failed", error);
}
// Pause locally too so the user sees instant feedback.
this.manager.getPlayerWrapper().localPause();
}
/** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */
seek(positionTicks: number): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({
seekRequestDto: { PositionTicks: positionTicks },
});
} catch (error) {
console.error("SyncPlay Controller.seek failed", error);
}
}
/**
* Start playback in the group. Expands containers (Series, Season,
* BoxSet, Playlist, single Episode w/ autoplay) into the real
* playable queue before broadcasting.
*
* Resolves once the SetNewQueue request completes; the server then
* broadcasts a PlayQueue update and Play command to every member.
*/
async play(options: PlayOptions): Promise<void> {
const api = this.manager.getApiClient();
const sendPlayRequest = async (items: BaseItemDto[]) => {
const queue = items
.map((item) => item.Id)
.filter((id): id is string => typeof id === "string");
await getSyncPlayApi(api).syncPlaySetNewQueue({
playRequestDto: {
PlayingQueue: queue,
PlayingItemPosition: options.startIndex ?? 0,
StartPositionTicks: options.startPositionTicks ?? 0,
},
});
};
try {
const sourceItems = options.items
? options.items
: await getItemsForPlayback(api, options.ids ?? []);
const items = await translateItemsForPlayback(api, sourceItems, options);
await sendPlayRequest(items);
} catch (error) {
console.error("SyncPlay Controller.play failed", error);
throw error;
}
}
/** Stop the group's playback. */
stop(): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayStop();
} catch (error) {
console.error("SyncPlay Controller.stop failed", error);
}
}
/** Jump to the next item in the group's queue. */
nextItem(): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({
nextItemRequestDto: {
PlaylistItemId: this.manager
.getQueueCore()
.getCurrentPlaylistItemId(),
} as unknown as Parameters<
ReturnType<typeof getSyncPlayApi>["syncPlayNextItem"]
>[0]["nextItemRequestDto"],
});
} catch (error) {
console.error("SyncPlay Controller.nextItem failed", error);
}
}
/** Jump to the previous item in the group's queue. */
previousItem(): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({
previousItemRequestDto: {
PlaylistItemId: this.manager
.getQueueCore()
.getCurrentPlaylistItemId(),
} as unknown as Parameters<
ReturnType<typeof getSyncPlayApi>["syncPlayPreviousItem"]
>[0]["previousItemRequestDto"],
});
} catch (error) {
console.error("SyncPlay Controller.previousItem failed", error);
}
}
/** Jump to a specific item in the queue by playlist item id. */
setCurrentPlaylistItem(playlistItemId: string): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({
setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId },
});
} catch (error) {
console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
}
}
/**
* Jump the group to `item`. If the item is already in the current queue
* (by `Id`), dispatches a cheap `SetPlaylistItem` so the queue stays
* intact. Otherwise starts a new playback request, which replaces the
* group's queue (matches jellyfin-web's playbackManager.play behavior
* when picking an episode from a different series/season).
*/
goToItem(item: BaseItemDto): void {
if (!item.Id) {
console.warn("SyncPlay Controller.goToItem called without item.Id");
return;
}
const queueEntry = this.manager
.getQueueCore()
.getPlaylist()
.find((q) => q.Id === item.Id);
if (queueEntry?.PlaylistItemId) {
this.setCurrentPlaylistItem(queueEntry.PlaylistItemId);
return;
}
void this.play({
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
}
}
export default Controller;

View File

@@ -0,0 +1,93 @@
/**
* Per-instance event emitter — replaces jellyfin-web's global `Events.trigger`
* bus. Listeners that throw are caught and logged so one bad listener can't
* break the rest.
*/
import { WaitForEventDefaultTimeout } from "./constants";
export class EventEmitter {
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
off(event: string, callback: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(callback);
}
emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach((callback) => {
try {
callback(...args);
} catch (error) {
console.error(
`SyncPlay EventEmitter: handler for "${event}" threw`,
error,
);
}
});
}
removeAllListeners(event?: string): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}
/**
* Resolve on the next emission of `event`, or reject after `timeoutMs`
* (or any event in `rejectEventTypes`). Cleans up every listener.
*/
export function waitForEventOnce(
emitter: EventEmitter,
event: string,
timeoutMs: number = WaitForEventDefaultTimeout,
rejectEventTypes?: string[],
): Promise<unknown[]> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const clearAll = () => {
emitter.off(event, handler);
if (timer) clearTimeout(timer);
if (Array.isArray(rejectEventTypes)) {
for (const eventName of rejectEventTypes) {
emitter.off(eventName, rejectCallback);
}
}
};
const handler = (...args: unknown[]) => {
clearAll();
resolve(args);
};
const rejectCallback = (...args: unknown[]) => {
clearAll();
reject(args[0] ?? new Error("rejected"));
};
if (timeoutMs) {
timer = setTimeout(() => {
clearAll();
reject(new Error("Timed out."));
}, timeoutMs);
}
emitter.on(event, handler);
if (Array.isArray(rejectEventTypes)) {
for (const eventName of rejectEventTypes) {
emitter.on(eventName, rejectCallback);
}
}
});
}

View File

@@ -0,0 +1,383 @@
/**
* SyncPlayManager — central orchestrator for a SyncPlay session.
*
* Owns the three "cores" (TimeSync, PlaybackCore, QueueCore) and the
* PlayerWrapper, and routes WebSocket events between them.
*
* Lifecycle:
* constructor → init() → (joinGroup → group-state-change "Idle"+) →
* group-state-change "Playing" → group-state-change "Paused" → ...
* → (leaveGroup) → destroy()
*
* Events emitted (provider listens):
* - `group-info-update` `(GroupInfoDto | null)`
* - `group-state-change` `(state: string, oldState: string)`
* - `enabled` `(isEnabled: boolean)`
* - `play-state-change` `(isFollowing: boolean)`
* - `playbackstart` / `playbackerror` — from PlayerWrapper hooks
* - `osd` `(action: SyncPlayOsdAction)`
* - `toast` `(messageKey: string)`
*
* The manager exposes a per-instance `EventEmitter` rather than upstream
* `Events.on(manager, ...)` — replaces the global Events bus pattern.
*/
import type { Api } from "@jellyfin/sdk";
import { Controller } from "./Controller";
import { PlaybackCore } from "./cores/PlaybackCore";
import { QueueCore } from "./cores/QueueCore";
import { TimeSync } from "./cores/TimeSync";
import { EventEmitter } from "./EventEmitter";
import { PendingPlaybackTracker } from "./player/PendingPlaybackTracker";
import { PlayerWrapper } from "./player/PlayerWrapper";
import { reconcileToGroupOnAttach } from "./player/reconcileToGroupOnAttach";
import type {
GroupInfoDto,
GroupUpdate,
PlayerControls,
PlayQueueUpdate,
SendCommand,
} from "./types";
/** Raw WebSocket message data shapes (already unwrapped by the hook). */
export class SyncPlayManager extends EventEmitter {
private apiClient: Api;
private playerWrapper: PlayerWrapper;
private timeSync: TimeSync;
private playbackCore: PlaybackCore;
private queueCore: QueueCore;
private pendingPlaybackTracker: PendingPlaybackTracker;
private controller: Controller;
/** Current group info. `null` when not in a group. */
private groupInfo: GroupInfoDto | null = null;
/** Is SyncPlay actively enabled (i.e., we're in a group)? */
private syncPlayEnabledAtPlayer = false;
/** Are we mirroring the group's commands locally? */
private followingGroupPlayback = true;
constructor(api: Api) {
super();
this.apiClient = api;
this.playerWrapper = new PlayerWrapper();
this.timeSync = new TimeSync(api);
this.playbackCore = new PlaybackCore();
this.queueCore = new QueueCore();
this.pendingPlaybackTracker = new PendingPlaybackTracker();
this.controller = new Controller();
}
/** Wire up cores. Called once after construction. */
init(): void {
this.playbackCore.init(this);
this.queueCore.init(this);
this.controller.init(this);
// Forward PlaybackCore OSD events to provider listeners.
this.playbackCore.on("osd", (...args) => {
this.emit("osd", ...args);
});
// Bridge optimistic pending Pause/Unpause → React state.
this.pendingPlaybackTracker.setChangeHandler((cmd) => {
this.emit("pending-playback-change", cmd);
});
this.timeSync.startPing();
}
/** Public controller for callers. */
getController(): Controller {
return this.controller;
}
/** Called by SyncPlayProvider when the user switches Jellyfin servers. */
updateApiClient(api: Api): void {
this.apiClient = api;
this.timeSync.updateApiClient(api);
}
getApiClient(): Api {
return this.apiClient;
}
getPlayerWrapper(): PlayerWrapper {
return this.playerWrapper;
}
getTimeSync(): TimeSync {
return this.timeSync;
}
getPlaybackCore(): PlaybackCore {
return this.playbackCore;
}
getQueueCore(): QueueCore {
return this.queueCore;
}
getPendingPlaybackTracker(): PendingPlaybackTracker {
return this.pendingPlaybackTracker;
}
// ===========================================================================
// WebSocket message handlers (called by useSyncPlayWebSocket)
// ===========================================================================
/**
* Handle a `SyncPlayGroupUpdate` WebSocket message.
*
* Cast: the SDK's `GroupUpdate.Type` union is narrower than what the
* server actually emits (it omits `SyncPlayIsDisabled`, `GroupUpdate`,
* `CreateGroupDenied`, `JoinGroupDenied`). Wire format is the source
* of truth here.
*/
processGroupUpdate(rawUpdate: GroupUpdate): void {
if (!rawUpdate) {
console.warn("SyncPlay processGroupUpdate: empty update");
return;
}
const update = rawUpdate as unknown as {
Type: string;
Data: unknown;
};
switch (update.Type) {
case "PlayQueue":
this.queueCore.updatePlayQueue(
this.apiClient,
update.Data as unknown as PlayQueueUpdate,
);
break;
case "UserJoined":
case "UserLeft":
// Group membership notifications — current group will follow
// via GroupUpdate, but emit a toast for friendliness.
this.emit("toast", `MessageSyncPlay${update.Type}`, update.Data);
break;
case "GroupJoined": {
this.groupInfo = update.Data as GroupInfoDto;
this.enableSyncPlay(this.groupInfo);
this.emit("group-update", this.groupInfo);
this.emit("toast", "MessageSyncPlayGroupJoined");
break;
}
case "GroupLeft":
case "NotInGroup":
case "SyncPlayIsDisabled": {
const previousState = this.groupInfo?.State;
this.groupInfo = null;
this.disableSyncPlay();
this.emit("group-update", null);
if (update.Type === "GroupLeft") {
this.emit("toast", "MessageSyncPlayGroupLeft");
}
if (previousState) {
this.emit("group-state-change", "Idle", previousState);
}
break;
}
case "GroupUpdate": {
const previousState = this.groupInfo?.State;
this.groupInfo = update.Data as GroupInfoDto;
this.emit("group-update", this.groupInfo);
const newState = this.groupInfo.State;
if (newState && newState !== previousState) {
this.emit("group-state-change", newState, previousState ?? "Idle");
}
break;
}
case "StateUpdate": {
const stateData = update.Data as {
State?: string;
PreviousState?: string;
Reason?: string;
};
const newState = stateData.State ?? "Idle";
const previousState = stateData.PreviousState ?? "Idle";
const reason = stateData.Reason;
if (this.groupInfo) {
this.groupInfo.State = newState as GroupInfoDto["State"];
this.emit("group-update", this.groupInfo);
}
this.emit("group-state-change", newState, previousState, reason);
// Server signals "Playing" or "Paused" → clear any in-flight
// optimistic tap state.
if (newState === "Playing" || newState === "Paused") {
this.pendingPlaybackTracker.clear();
}
break;
}
case "CreateGroupDenied":
this.emit("toast", "MessageSyncPlayCreateGroupDenied");
break;
case "JoinGroupDenied":
this.emit("toast", "MessageSyncPlayJoinGroupDenied");
break;
case "LibraryAccessDenied":
this.emit("toast", "MessageSyncPlayLibraryAccessDenied");
break;
case "GroupDoesNotExist":
this.emit("toast", "MessageSyncPlayGroupDoesNotExist");
break;
default:
console.warn("SyncPlay processGroupUpdate: unknown type", update.Type);
break;
}
}
/** Handle a `SyncPlayCommand` WebSocket message. */
processCommand(command: SendCommand): void {
if (!command) {
console.warn("SyncPlay processCommand: empty command");
return;
}
this.playbackCore.applyCommand(command);
// Server told us the new playing state — clear optimistic UI.
if (command.Command === "Unpause" || command.Command === "Pause") {
this.pendingPlaybackTracker.clear();
}
}
// ===========================================================================
// Enable / disable SyncPlay
// ===========================================================================
private enableSyncPlay(_group: GroupInfoDto): void {
if (this.syncPlayEnabledAtPlayer) return;
this.syncPlayEnabledAtPlayer = true;
this.followingGroupPlayback = true;
this.timeSync.forceUpdate();
this.emit("enabled", true);
this.emit("play-state-change", true);
}
private disableSyncPlay(): void {
if (!this.syncPlayEnabledAtPlayer) return;
this.syncPlayEnabledAtPlayer = false;
this.followingGroupPlayback = false;
this.playbackCore.clearScheduledCommand();
this.queueCore.clear();
this.pendingPlaybackTracker.clear();
this.emit("enabled", false);
this.emit("play-state-change", false);
}
/**
* Resume following group playback after the user temporarily took
* local control (e.g. scrubbed the seek bar).
*/
async followGroupPlayback(_api: Api): Promise<void> {
this.followingGroupPlayback = true;
this.emit("play-state-change", true);
}
/** Stop following group playback (e.g., user takes local control). */
haltGroupPlayback(_api: Api): void {
this.followingGroupPlayback = false;
this.emit("play-state-change", false);
}
isFollowingGroupPlayback(): boolean {
return this.followingGroupPlayback;
}
isSyncPlayEnabled(): boolean {
return this.syncPlayEnabledAtPlayer;
}
// ===========================================================================
// Player attach + provider bridges
// ===========================================================================
/**
* Bind the RN player controls.
* Called from the player screen's `useEffect`. Triggers a reconcile
* if a group is active and the player is late-arriving.
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerWrapper.bindToControls(controls);
if (controls && this.syncPlayEnabledAtPlayer) {
const lastCommand = this.playbackCore.getLastCommand();
reconcileToGroupOnAttach(controls, lastCommand, (local) =>
this.timeSync.localDateToRemote(local),
);
}
}
/** Player-side notify hook: media is ready to play. */
notifyReady(): void {
this.emit("playbackstart");
if (this.syncPlayEnabledAtPlayer) {
this.playbackCore.onReady(this.apiClient);
}
}
/** Player-side notify hook: buffering state changed. */
notifyBuffering(isBuffering: boolean): void {
if (!this.syncPlayEnabledAtPlayer) return;
if (isBuffering) {
this.playbackCore.onBuffering(this.apiClient);
} else {
this.playbackCore.onReady(this.apiClient);
}
}
/** Player-side notify hook: local playback started. */
notifyPlaybackStart(): void {
this.emit("playbackstart");
if (this.syncPlayEnabledAtPlayer) {
this.playbackCore.onPlaybackStart(this.apiClient);
}
}
// ===========================================================================
// Pending playback (optimistic UI for play/pause taps)
// ===========================================================================
/** Called by Controller before sending an Unpause/Pause request. */
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
this.pendingPlaybackTracker.mark(command);
}
/** Is the group currently playing? Used by Controller.playPause. */
isPlaying(): boolean {
const pending = this.pendingPlaybackTracker.get();
if (pending === "Unpause") return true;
if (pending === "Pause") return false;
return this.groupInfo?.State === "Playing";
}
/** Group info for consumers. */
getGroupInfo(): GroupInfoDto | null {
return this.groupInfo;
}
/** Last playback command (for QueueCore.startPlayback resumption). */
getLastPlaybackCommand(): SendCommand | null {
return this.playbackCore.getLastCommand();
}
// ===========================================================================
// Teardown
// ===========================================================================
destroy(): void {
this.timeSync.destroy();
this.playbackCore.destroy();
this.queueCore.destroy();
this.playerWrapper.bindToControls(null);
this.removeAllListeners();
}
}
export default SyncPlayManager;

View File

@@ -0,0 +1,600 @@
/**
* SyncPlayProvider — React glue around `SyncPlayManager`.
*
* Responsibilities:
* - Manager lifecycle (construct on api change, destroy on unmount)
* - React mirrors of manager state (`isEnabled`, `groupInfo`,
* `pendingPlaybackCommand`) so components re-render
* - Navigation handlers wired into `PlayerWrapper.localPlay` /
* `localSetCurrentPlaylistItem` — these are what jellyfin-web does
* synchronously via `playbackManager.play`; on RN they navigate
* to the player screen instead
* - AppState foreground re-join (we may miss broadcasts while
* suspended)
*
* External API surface (`useSyncPlay`) is stable; components don't
* change when the internals do.
*/
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { usePathname } from "expo-router";
import { useAtomValue } from "jotai";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { toast } from "sonner-native";
import { useAppRouter } from "@/hooks/useAppRouter";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import i18n from "@/i18n";
import { getDownloadedItemById } from "@/providers/Downloads";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { Controller as SyncPlayController } from "./Controller";
import { SyncPlayManager } from "./Manager";
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";
interface SyncPlayContextValue {
isEnabled: boolean;
groupInfo: GroupInfoDto | null;
canJoinGroups: boolean;
canCreateGroups: boolean;
joinGroup: (groupId: string) => Promise<void>;
createGroup: (groupName?: string) => Promise<void>;
leaveGroup: () => Promise<void>;
getGroups: () => Promise<GroupInfoDto[]>;
/**
* Re-attach to the group's command stream and jump back to the
* group's currently-playing item. Mirrors jellyfin-web's "Resume
* playback" menu entry: in jellyfin-web it just calls
* `playbackManager.play` on the group's current queue position.
* Here we navigate to direct-player with the same params our
* `localSetCurrentItem` bridge would use, so the player picks up
* mid-group with `syncPlay=true` and the right offset.
*/
resumeGroupPlayback: () => Promise<void>;
controller: SyncPlayController | null;
setPlayerControls: (controls: PlayerControls | null) => void;
notifyReady: () => void;
notifyBuffering: (isBuffering: boolean) => void;
notifyPlaybackStart: () => void;
pendingPlaybackCommand: "Unpause" | "Pause" | null;
/**
* Current SyncPlay OSD overlay state. Drives the animated icon over the
* video that mirrors jellyfin-web's `#syncPlayIcon`. `null` means hidden.
*/
osdAction: SyncPlayOsdAction | null;
}
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
interface SyncPlayProviderProps {
children: ReactNode;
}
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useAppRouter();
const { isConnected: isWsConnected } = useWebSocketContext();
const [manager, setManager] = useState<SyncPlayManager | null>(null);
const isNavigatingToPlayerRef = useRef(false);
// Keep a live ref of the current route pathname so the
// navigateToPlayer helper (wired up once inside the manager-lifecycle
// effect) can read the *current* page without stale-closure issues.
const pathname = usePathname();
const pathnameRef = useRef(pathname);
useEffect(() => {
pathnameRef.current = pathname;
}, [pathname]);
const [isEnabled, setIsEnabled] = useState(false);
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
"Unpause" | "Pause" | null
>(null);
// While in a SyncPlay group, hold a keep-alive token on the global
// WebSocket so backgrounding the app does NOT cleanly close the
// socket. A clean close is interpreted by the Jellyfin server as
// leaving the group and is broadcast to every other member as
// "<user> has left the group". Keeping the socket open across a
// short suspend lets us stay in the group while quickly switching
// apps; if the OS eventually tears the TCP connection down anyway,
// the app-foreground rejoin effect below will pull us back in.
useKeepWebSocketAlive(isEnabled);
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
const osdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Set the OSD overlay action.
*
* `transient` mirrors jellyfin-web's `iconVisibilityTime = 1500` for the
* Unpause / Pause / Seek command-confirmation flashes. Persistent actions
* (schedule-play, buffering, wait-*) stay until cleared by a state
* transition or a subsequent call with `null`.
*/
const showOsd = useCallback(
(action: SyncPlayOsdAction | null, transient = false) => {
if (osdTimeoutRef.current) {
clearTimeout(osdTimeoutRef.current);
osdTimeoutRef.current = null;
}
setOsdAction(action);
if (transient && action !== null) {
osdTimeoutRef.current = setTimeout(() => {
osdTimeoutRef.current = null;
setOsdAction((cur) => (cur === action ? null : cur));
}, 1500);
}
},
[],
);
// Pending play/pause tap → optimistic schedule-play overlay (unless another
// overlay reason has already taken precedence).
useEffect(() => {
if (pendingPlaybackCommand) {
setOsdAction((cur) => cur ?? "schedule-play");
} else {
setOsdAction((cur) => (cur === "schedule-play" ? null : cur));
}
}, [pendingPlaybackCommand]);
// Clear the OSD auto-expire timeout on unmount.
useEffect(() => {
return () => {
if (osdTimeoutRef.current) {
clearTimeout(osdTimeoutRef.current);
osdTimeoutRef.current = null;
}
};
}, []);
const canJoinGroups = useMemo(() => {
const access = user?.Policy?.SyncPlayAccess;
return access !== "None" && access !== undefined;
}, [user?.Policy?.SyncPlayAccess]);
const canCreateGroups = useMemo(
() => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups",
[user?.Policy?.SyncPlayAccess],
);
// Latch: `true` once we've fired the per-attach `playbackstart` event.
const playbackStartFiredRef = useRef(false);
// ---------------------------------------------------------------------------
// Navigation to the player screen
// ---------------------------------------------------------------------------
/*
* Single navigate-to-direct-player helper, used by every code path
* that needs to (re-)open the player while in a SyncPlay group:
* - localPlay (group's leader started a new queue / we just joined)
* - localSetCurrentPlaylistItem (group advanced to next episode)
* - resumeGroupPlayback (user tapped "Resume playback" in the menu)
*
* Both jellyfin-web's playbackManager.play and its setCurrentPlaylistItem
* collapse to "point the player at this item / position" — RN is the
* same shape, just a router navigation instead of an in-page DOM swap.
*
* Note: no "joining playback" toast here — the `GroupJoined`
* WebSocket event already triggers a "Joined group" toast via
* `Manager.ts`, and showing both on a fresh join was redundant.
*/
const navigateToPlayer = useCallback(
(itemId: string, startPositionTicks: number) => {
if (isNavigatingToPlayerRef.current) {
console.debug("SyncPlay: already navigating to player");
return;
}
isNavigatingToPlayerRef.current = true;
// Opportunistic local playback: if we have a downloaded copy of
// the target item, use it instead of streaming. Matters most when
// the group advances to an episode you've downloaded — the local
// file starts instantly and survives spotty wifi. SyncPlay's
// position/pause/seek commands keep flowing normally; only the
// source changes.
const isDownloaded = !!getDownloadedItemById(itemId);
const queryParams = new URLSearchParams({
itemId,
playbackPosition: String(startPositionTicks),
syncPlay: "true",
...(isDownloaded && { offline: "true" }),
}).toString();
// Use `replace` when we're already on the player screen so queue
// advances don't stack a second player on the nav stack; `push`
// otherwise so the user can back out to where they came from.
const onPlayerScreen =
pathnameRef.current?.startsWith("/player/direct-player") ?? false;
if (onPlayerScreen) {
router.replace(`/player/direct-player?${queryParams}`);
} else {
router.push(`/player/direct-player?${queryParams}`);
}
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
},
[router],
);
// ---------------------------------------------------------------------------
// Manager lifecycle
// ---------------------------------------------------------------------------
useEffect(() => {
if (!api) return;
const mgr = new SyncPlayManager(api);
mgr.init();
setManager(mgr);
const playerWrapper = mgr.getPlayerWrapper();
// localPlay → navigate to direct-player with syncPlay=true
playerWrapper.setLocalPlayHandler((options) => {
const itemId = options.ids[0];
if (!itemId) {
console.warn("SyncPlay: localPlay called with no ids");
return;
}
navigateToPlayer(itemId, options.startPositionTicks ?? 0);
});
// localSetCurrentPlaylistItem → navigate to the new playlist item
playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => {
if (!playlistItemId) return;
const queueCore = mgr.getQueueCore();
const target = queueCore
.getPlaylist()
.find((i) => i.PlaylistItemId === playlistItemId);
const itemId = target?.Id;
if (!itemId) {
console.warn(
"SyncPlay: localSetCurrentPlaylistItem — item not in playlist",
playlistItemId,
);
return;
}
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
});
mgr.on("enabled", (...args: unknown[]) => {
const enabled = args[0] as boolean;
setIsEnabled(enabled);
if (!enabled) setGroupInfo(null);
});
mgr.on("group-update", (...args: unknown[]) => {
setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
});
mgr.on("pending-playback-change", (...args: unknown[]) => {
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
});
// group-state-change → on "Waiting", pause locally so we don't drift
// ahead of the group while the server is reconciling buffering/seek
// state. Position resync is *only* done from the explicit Pause /
// Unpause / Seek SendCommands that follow (`PlaybackCore.applyCommand`
// → `scheduleUnpause` etc.) — those commands carry the canonical
// `PositionTicks` for the action's `When`. The old code here also
// called `wrapper.localSeek(lastCommand.PositionTicks)`, but
// `lastCommand` is the *previous* Pause/Unpause and can be many
// seconds stale, so it rewound the user every time someone else
// buffered. Don't put a seek back here.
mgr.on("group-state-change", (...args: unknown[]) => {
const state = args[0] as string | undefined;
const reason = args[2] as string | undefined;
const wrapper = mgr.getPlayerWrapper();
if (!wrapper.isPlaybackActive()) return;
if (state === "Waiting") {
wrapper.localPause();
}
// Drive the persistent OSD overlay from (state, reason).
// Mirrors jellyfin-web's `group-state-update` → `showIcon` mapping.
if (state === "Waiting") {
if (reason === "Buffer") showOsd("buffering");
else if (reason === "Unpause") showOsd("wait-unpause");
else if (reason === "Pause") showOsd("wait-pause");
else if (reason === "Seek") showOsd("seek");
} else if (state === "Playing" || state === "Paused") {
// Stable state — clear any persistent overlay; transient flashes
// come from the `osd` event below and self-expire.
setOsdAction((cur) => {
if (
cur === "schedule-play" ||
cur === "buffering" ||
cur === "wait-pause" ||
cur === "wait-unpause"
) {
return null;
}
return cur;
});
}
});
// PlaybackCore-emitted OSD events. Transient ones auto-expire in 1.5s.
mgr.on("osd", (...args: unknown[]) => {
const action = args[0] as SyncPlayOsdAction;
const transient =
action === "unpause" || action === "pause" || action === "seek";
showOsd(action, transient);
});
mgr.on("toast", (...args: unknown[]) => {
const key = args[0] as string;
const arg = args[1] as string | undefined;
const message = arg
? i18n.t(`syncplay.toasts.${key}`, { user: arg })
: i18n.t(`syncplay.toasts.${key}`);
toast(message);
});
return () => {
mgr.destroy();
setManager(null);
};
}, [api, navigateToPlayer]);
// Initial join race: once `enabled` flips true, snapshot the current group.
useEffect(() => {
if (isEnabled && manager) {
setGroupInfo(manager.getGroupInfo());
}
}, [isEnabled, manager]);
// Wire WebSocket messages → manager
useSyncPlayWebSocket(manager);
// ---------------------------------------------------------------------------
// Group management
// ---------------------------------------------------------------------------
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
if (!api) return [];
try {
const response = await getSyncPlayApi(api).syncPlayGetGroups();
return (response.data as unknown as GroupInfoDto[]) ?? [];
} catch (error) {
console.error("SyncPlay: failed to get groups", error);
return [];
}
}, [api]);
const joinGroup = useCallback(
async (groupId: string): Promise<void> => {
if (!api) return;
try {
await getSyncPlayApi(api).syncPlayJoinGroup({
joinGroupRequestDto: { GroupId: groupId },
});
} catch (error) {
console.error("SyncPlay: failed to join group", error);
throw error;
}
},
[api],
);
const createGroup = useCallback(
async (groupName?: string): Promise<void> => {
if (!api || !user) return;
const name = groupName || `${user.Name}'s Group`;
try {
await getSyncPlayApi(api).syncPlayCreateGroup({
newGroupRequestDto: { GroupName: name },
});
} catch (error) {
console.error("SyncPlay: failed to create group", error);
throw error;
}
},
[api, user],
);
const leaveGroup = useCallback(async (): Promise<void> => {
if (!api) return;
try {
await getSyncPlayApi(api).syncPlayLeaveGroup();
} catch (error) {
console.error("SyncPlay: failed to leave group", error);
throw error;
}
}, [api]);
/*
* Resume playback: re-follow the group's command stream and jump
* the local player to the group's current item + position. This is
* the only entry point a user needs from the menu — there is no
* separate "halt" UI; the player exit/back already detaches us.
*/
const resumeGroupPlayback = useCallback(async (): Promise<void> => {
if (!api || !manager) return;
await manager.followGroupPlayback(api);
const queueCore = manager.getQueueCore();
const index = queueCore.getCurrentPlaylistIndex();
const itemId =
index >= 0 ? (queueCore.getPlaylist()[index]?.Id ?? null) : null;
if (!itemId) {
console.warn("SyncPlay: resumeGroupPlayback — no current group item");
return;
}
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
}, [api, manager, navigateToPlayer]);
// ---------------------------------------------------------------------------
// App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
// ---------------------------------------------------------------------------
const lastGroupIdRef = useRef<string | null>(null);
useEffect(() => {
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
}, [groupInfo?.GroupId]);
// Track whether the WebSocket got torn down while the app was
// backgrounded. If it survived (keep-alive worked), the server
// still has us in the group and we must NOT call JoinGroup again —
// doing so would trigger a redundant "X joined the group" broadcast
// to every other member every time we briefly leave the app.
const wsClosedWhileBackgroundedRef = useRef(false);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
if (!isWsConnected && appStateRef.current !== "active") {
wsClosedWhileBackgroundedRef.current = true;
}
}, [isWsConnected]);
useEffect(() => {
if (!api) return;
const subscription = AppState.addEventListener("change", (nextAppState) => {
const previousAppState = appStateRef.current;
appStateRef.current = nextAppState;
const becameActive =
(previousAppState === "background" ||
previousAppState === "inactive") &&
nextAppState === "active";
if (!becameActive) return;
const groupId = lastGroupIdRef.current;
if (!groupId) return;
// Happy path: keep-alive held the socket open across the
// suspend. Server still considers us a member — nothing to do.
if (!wsClosedWhileBackgroundedRef.current) {
console.log(
"SyncPlay: app foregrounded with WS still alive, skipping rejoin",
);
return;
}
wsClosedWhileBackgroundedRef.current = false;
// Small delay so the WebSocket has a moment to reconnect.
setTimeout(() => {
console.log(
`SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`,
);
getSyncPlayApi(api)
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
.catch((error) => {
console.error("SyncPlay: failed to rejoin group", error);
});
}, 1000);
});
return () => subscription.remove();
}, [api]);
// ---------------------------------------------------------------------------
// Player attach bridges
// ---------------------------------------------------------------------------
const setPlayerControls = useCallback(
(controls: PlayerControls | null) => {
// Reset the playbackstart latch on each new attach.
playbackStartFiredRef.current = false;
manager?.setPlayerControls(controls);
},
[manager],
);
const notifyReady = useCallback(() => {
manager?.notifyReady();
}, [manager]);
const notifyBuffering = useCallback(
(isBuffering: boolean) => {
manager?.notifyBuffering(isBuffering);
if (!isBuffering && !playbackStartFiredRef.current) {
playbackStartFiredRef.current = true;
manager?.notifyPlaybackStart();
}
},
[manager],
);
const notifyPlaybackStart = useCallback(() => {
manager?.notifyPlaybackStart();
}, [manager]);
// ---------------------------------------------------------------------------
// Context value
// ---------------------------------------------------------------------------
const contextValue: SyncPlayContextValue = useMemo(
() => ({
isEnabled,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
resumeGroupPlayback,
controller: manager?.getController() ?? null,
setPlayerControls,
notifyReady,
notifyBuffering,
notifyPlaybackStart,
pendingPlaybackCommand,
osdAction,
}),
[
isEnabled,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
resumeGroupPlayback,
manager,
setPlayerControls,
notifyReady,
notifyBuffering,
notifyPlaybackStart,
pendingPlaybackCommand,
osdAction,
],
);
return (
<SyncPlayContext.Provider value={contextValue}>
{children}
</SyncPlayContext.Provider>
);
}
export function useSyncPlay(): SyncPlayContextValue {
const context = useContext(SyncPlayContext);
if (!context) {
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
}
return context;
}

View File

@@ -0,0 +1,23 @@
/**
* Constants — shared timing/threshold values used across SyncPlay files.
* Kept separate from `types.ts` because these are implementation tuning
* values, not the public protocol/types surface.
*/
import { TicksPerMillisecond } from "./types";
export { TicksPerMillisecond };
/** Default timeout for `waitForEventOnce` (matches jellyfin-web). */
export const WaitForEventDefaultTimeout = 30000;
/** Short-lived timeout for player events (matches jellyfin-web). */
export const WaitForPlayerEventTimeout = 500;
export function ticksToMs(ticks: number): number {
return ticks / TicksPerMillisecond;
}
export function msToTicks(ms: number): number {
return Math.round(ms * TicksPerMillisecond);
}

View File

@@ -0,0 +1,381 @@
/**
* SyncPlay PlaybackCore — schedules unpauses/pauses/seeks/stops to fire
* at the precise group-wide moment and keeps the player drift-corrected.
*
* Design choices that diverge from jellyfin-web:
* - **No SpeedToSync**. Our RN players' `setPlaybackSpeed` is unreliable
* across platforms (mpv/VLC/expo-video each behave differently for
* fractional speeds). We always seek to catch up.
* - **No `MessageSyncPlayDuplicateMedia` detection**. Web's detection
* used HTML element identity; on RN we don't have a stable handle
* and the false-positive rate would be much higher than the value.
* - **No syncMethod / showSyncIcon**. We don't surface the sync
* technique to the UI.
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import {
TicksPerMillisecond,
ticksToMs,
WaitForPlayerEventTimeout,
} from "../constants";
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
import type { SyncPlayManager } from "../Manager";
import { type SendCommand, SYNC_PLAY_TUNING } from "../types";
export class PlaybackCore extends EventEmitter {
private manager!: SyncPlayManager;
private lastCommand: SendCommand | null = null;
private scheduledCommand: ReturnType<typeof setTimeout> | null = null;
init(manager: SyncPlayManager): void {
this.manager = manager;
}
/** Local "playback started" hook — fires the initial Ready request. */
onPlaybackStart(apiClient: Api): void {
try {
const playerWrapper = this.manager.getPlayerWrapper();
const positionMs = playerWrapper.currentTime();
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
const playlistItemId =
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
const now = this.manager.getTimeSync().localDateToRemote(new Date());
getSyncPlayApi(apiClient).syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: positionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId,
},
});
} catch (error) {
console.error("SyncPlay onPlaybackStart:", error);
}
}
/** Local pause → tell the server. */
onPause(apiClient: Api): void {
try {
getSyncPlayApi(apiClient).syncPlayPause();
} catch (error) {
console.error("SyncPlay onPause:", error);
}
}
/** Local unpause → tell the server. */
onUnpause(apiClient: Api): void {
try {
getSyncPlayApi(apiClient).syncPlayUnpause();
} catch (error) {
console.error("SyncPlay onUnpause:", error);
}
}
/** Local "ready" hook — server uses this to know we've finished buffering. */
onReady(apiClient: Api): void {
this.sendBufferingRequest(apiClient, false);
}
/** Local "buffering" hook — server uses this to (optionally) pause the group. */
onBuffering(apiClient: Api): void {
this.sendBufferingRequest(apiClient, true);
}
/** Send a Ready or Buffering request. */
sendBufferingRequest(apiClient: Api, isBuffering: boolean): void {
const playerWrapper = this.manager.getPlayerWrapper();
const positionMs = playerWrapper.currentTime();
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
const playlistItemId =
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
const now = this.manager.getTimeSync().localDateToRemote(new Date());
try {
if (isBuffering) {
getSyncPlayApi(apiClient).syncPlayBuffering({
bufferRequestDto: {
When: now.toISOString(),
PositionTicks: positionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId,
},
});
} else {
getSyncPlayApi(apiClient).syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: positionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId,
},
});
}
} catch (error) {
console.error("SyncPlay sendBufferingRequest:", error);
}
}
/**
* Apply a group command (Unpause, Pause, Stop, Seek). Times the
* execution to fire at the group-wide instant the server selected.
*/
applyCommand(command: SendCommand): void {
(command as unknown as { EmittedAt: Date }).EmittedAt = new Date(
command.EmittedAt as unknown as string,
);
(command as unknown as { When: Date }).When = new Date(
command.When as unknown as string,
);
// Duplicate-detection — mirrors jellyfin-web's PlaybackCore.applyCommand.
// The server can redeliver the same command (WebSocket reconnect, multiple
// group-state transitions referencing the same instant, etc). If every
// identifying field matches the previously applied command, we don't
// re-schedule — we just verify player state still matches and bail.
//
// IMPORTANT: this is NOT a monotonic-clock check. `When` is the scheduled
// execution time and can legitimately move backward between commands
// (e.g. a Pause emitted now with `When = now` arriving after an earlier
// Unpause whose `When` was scheduled 10s in the future). An earlier
// version of this code rejected anything whose `When` or `EmittedAt`
// wasn't strictly greater than `lastCommand`'s — that silently locked
// out every subsequent pause/unpause whenever group playback first
// started with a future-scheduled Unpause.
if (
this.lastCommand &&
(this.lastCommand as unknown as { When: Date }).When.getTime() ===
(command as unknown as { When: Date }).When.getTime() &&
this.lastCommand.PositionTicks === command.PositionTicks &&
this.lastCommand.Command === command.Command &&
this.lastCommand.PlaylistItemId === command.PlaylistItemId
) {
console.debug("SyncPlay applyCommand: duplicate command", command);
return;
}
this.lastCommand = command;
if (!this.manager.isFollowingGroupPlayback()) {
console.debug(
"SyncPlay applyCommand: dropping command (not following playback)",
command,
);
return;
}
const playerWrapper = this.manager.getPlayerWrapper();
if (!playerWrapper.isPlaybackActive()) {
console.debug(
"SyncPlay applyCommand: dropping command (playback not active)",
command,
);
return;
}
const enqueuedAt = new Date();
const remoteEnqueuedAt = this.manager
.getTimeSync()
.localDateToRemote(enqueuedAt);
const localCommandWhen = this.manager
.getTimeSync()
.remoteDateToLocal(command.When as unknown as Date);
switch (command.Command) {
case "Unpause":
this.scheduleUnpause(localCommandWhen, command.PositionTicks ?? 0);
this.emit("osd", "unpause");
break;
case "Pause":
this.schedulePause(localCommandWhen, command.PositionTicks ?? 0);
this.emit("osd", "pause");
break;
case "Stop":
this.scheduleStop(localCommandWhen);
break;
case "Seek":
this.scheduleSeek(localCommandWhen, command.PositionTicks ?? 0);
this.emit("osd", "seek");
break;
default:
console.warn("SyncPlay applyCommand: unknown command", command);
break;
}
if (
(command as unknown as { When: Date }).When.getTime() <
remoteEnqueuedAt.getTime()
) {
console.debug(
"SyncPlay applyCommand: command was scheduled for the past",
command,
);
}
}
/** Pre-Unpause hook: emit a wait OSD while we wait for the moment. */
scheduleUnpause(when: Date, positionTicks: number): void {
this.clearScheduledCommand();
const now = Date.now();
const playAtTime = when.getTime();
const currentPositionMs = this.manager.getPlayerWrapper().currentTime();
const currentPositionTicks = Math.round(
currentPositionMs * TicksPerMillisecond,
);
if (playAtTime > now) {
// Future: seek now, then play at the right moment.
this.localSeek(positionTicks);
this.scheduledCommand = setTimeout(() => {
this.localUnpause();
// After playback resumes, the player position will need a
// small bump to land on the group target. waitForPlayerEvent
// is best-effort.
waitForEventOnce(
this.manager,
"unpause",
WaitForPlayerEventTimeout,
).catch(() => undefined);
}, playAtTime - now);
this.emit("osd", "wait-unpause");
} else {
// Past: catch up now.
const targetMs = ticksToMs(positionTicks);
const delayMs = now - playAtTime;
this.localSeek(Math.round((targetMs + delayMs) * TicksPerMillisecond));
this.localUnpause();
void currentPositionTicks;
}
}
schedulePause(when: Date, positionTicks: number): void {
this.clearScheduledCommand();
const now = Date.now();
const pauseAtTime = when.getTime();
const callback = () => {
this.localUnpause();
this.localSeek(positionTicks);
this.localPause();
};
if (pauseAtTime > now) {
this.scheduledCommand = setTimeout(callback, pauseAtTime - now);
this.emit("osd", "wait-pause");
} else {
callback();
}
}
scheduleStop(when: Date): void {
this.clearScheduledCommand();
const now = Date.now();
const stopAtTime = when.getTime();
if (stopAtTime > now) {
this.scheduledCommand = setTimeout(() => {
this.localStop();
}, stopAtTime - now);
} else {
this.localStop();
}
}
scheduleSeek(when: Date, positionTicks: number): void {
this.applyCommand({
...this.lastCommand!,
Command: "Pause",
PositionTicks: positionTicks,
When: when as unknown as string,
EmittedAt: new Date().toISOString(),
});
}
clearScheduledCommand(): void {
if (this.scheduledCommand) {
clearTimeout(this.scheduledCommand);
this.scheduledCommand = null;
}
}
// -- local player ops ------------------------------------------------------
localUnpause(): void {
this.manager.getPlayerWrapper().localUnpause();
}
localPause(): void {
this.manager.getPlayerWrapper().localPause();
}
localSeek(positionTicks: number): void {
this.manager.getPlayerWrapper().localSeek(positionTicks);
}
localStop(): void {
this.manager.getPlayerWrapper().localStop();
}
// -- queries ---------------------------------------------------------------
getLastCommand(): SendCommand | null {
return this.lastCommand;
}
/**
* Estimate where the group should be in ticks, given a known
* starting position and the time the position was valid at.
*/
estimateCurrentTicks(positionTicks: number, when: Date): number {
const lastCommand = this.lastCommand;
if (!lastCommand) return positionTicks;
const remoteNow = this.manager.getTimeSync().localDateToRemote(new Date());
const elapsedMs = remoteNow.getTime() - when.getTime();
if (lastCommand.Command === "Unpause") {
return positionTicks + elapsedMs * TicksPerMillisecond;
}
return positionTicks;
}
/**
* Drift correction tick — called on every player time update. Skips
* to the group's expected position if drift exceeds the threshold.
* SpeedToSync is intentionally not implemented (see file header).
*/
syncPlaybackTime(): void {
const lastCommand = this.lastCommand;
if (lastCommand?.Command !== "Unpause") return;
const playerWrapper = this.manager.getPlayerWrapper();
if (!playerWrapper.isPlaying()) return;
const currentMs = playerWrapper.currentTime();
const expectedTicks = this.estimateCurrentTicks(
lastCommand.PositionTicks ?? 0,
lastCommand.When as unknown as Date,
);
const expectedMs = ticksToMs(expectedTicks);
const driftMs = Math.abs(currentMs - expectedMs);
if (driftMs > SYNC_PLAY_TUNING.minDelaySkipToSync) {
console.log(
`SyncPlay syncPlaybackTime: drift ${driftMs.toFixed(
0,
)}ms exceeds threshold, seeking to ${expectedMs.toFixed(0)}ms`,
);
this.localSeek(expectedTicks);
}
}
// -- teardown --------------------------------------------------------------
destroy(): void {
this.clearScheduledCommand();
this.lastCommand = null;
this.removeAllListeners();
}
}
export default PlaybackCore;

View File

@@ -0,0 +1,332 @@
/**
* SyncPlay QueueCore — tracks the group's playlist.
*
* Responsibilities:
* - Handle `PlayQueue` group updates (NewPlaylist, SetCurrentItem,
* NextItem, PreviousItem, RemoveItems, etc.)
* - Resolve the server's flat list of ItemIds into full `BaseItemDto`s
* (with PlaylistItemId glued on for SyncPlay requests)
* - Expose `currentPlaylistItemId` — required by every SyncPlay
* request (Ready, Buffering, Seek) so the server can ignore stale
* ones from before the playlist moved
* - On NewPlaylist, ask the server we're ready by sending a Buffering
* request after the local player emits `playbackstart`
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { TicksPerMillisecond, WaitForEventDefaultTimeout } from "../constants";
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
import type { SyncPlayManager } from "../Manager";
import {
getItemsForPlayback,
translateItemsForPlayback,
} from "../transport/queueTranslation";
import type {
PlayQueueUpdate,
PlayQueueUpdateReason,
SyncPlayQueueItem,
} from "../types";
export class QueueCore extends EventEmitter {
private manager!: SyncPlayManager;
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
/** Playable items with `PlaylistItemId` glued on. */
private playlist: BaseItemDto[] = [];
init(manager: SyncPlayManager): void {
this.manager = manager;
}
/** Handle a PlayQueue group update from the server. */
updatePlayQueue(apiClient: Api, newPlayQueue: PlayQueueUpdate): void {
(newPlayQueue as unknown as { LastUpdate: Date }).LastUpdate = new Date(
newPlayQueue.LastUpdate as unknown as string,
);
if (
(newPlayQueue.LastUpdate as unknown as Date).getTime() <=
this.getLastUpdateTime()
) {
console.debug("SyncPlay updatePlayQueue: ignoring old update");
return;
}
this.onPlayQueueUpdate(apiClient, newPlayQueue)
.then(() => {
if (
(newPlayQueue.LastUpdate as unknown as Date).getTime() <
this.getLastUpdateTime()
) {
console.warn("SyncPlay updatePlayQueue: trying to apply old update");
return;
}
const reason = newPlayQueue.Reason as PlayQueueUpdateReason;
switch (reason) {
case "NewPlaylist": {
if (!this.manager.isFollowingGroupPlayback()) {
this.manager.followGroupPlayback(apiClient).then(() => {
this.startPlayback(apiClient);
});
} else {
this.startPlayback(apiClient);
}
break;
}
case "SetCurrentItem":
case "NextItem":
case "PreviousItem": {
const playlistItemId = this.getCurrentPlaylistItemId();
this.setCurrentPlaylistItem(apiClient, playlistItemId);
break;
}
case "RemoveItems":
case "MoveItem":
case "Queue":
case "QueueNext":
case "RepeatMode":
case "ShuffleMode":
// Video-focused: we don't expose repeat/shuffle/queue mutation
// controls in the RN UI yet, so these reasons just update our
// local snapshot (already done by onPlayQueueUpdate) without
// triggering any local action.
break;
default:
console.warn(
"SyncPlay updatePlayQueue: unknown reason",
newPlayQueue.Reason,
);
break;
}
})
.catch((error) => {
console.warn("SyncPlay updatePlayQueue:", error);
});
}
/** Apply a play-queue update to local state. */
async onPlayQueueUpdate(
apiClient: Api,
playQueueUpdate: PlayQueueUpdate,
): Promise<void> {
const itemIds = (playQueueUpdate.Playlist ?? [])
.map((queueItem: SyncPlayQueueItem) => queueItem.ItemId)
.filter((id): id is string => typeof id === "string");
if (!itemIds.length) {
this.lastPlayQueueUpdate = playQueueUpdate;
this.playlist = [];
return;
}
const fetched = await getItemsForPlayback(apiClient, itemIds);
const items = await translateItemsForPlayback(apiClient, fetched, {
ids: itemIds,
});
if (
this.lastPlayQueueUpdate &&
(playQueueUpdate.LastUpdate as unknown as Date).getTime() <=
this.getLastUpdateTime()
) {
throw new Error("Trying to apply old update");
}
// Glue PlaylistItemId from the server's playlist entries onto each
// resolved item. The server-assigned IDs are what every SyncPlay
// request needs to identify the queue slot.
const playlistItems = playQueueUpdate.Playlist ?? [];
for (let i = 0; i < items.length && i < playlistItems.length; i++) {
items[i].PlaylistItemId = playlistItems[i].PlaylistItemId;
}
this.lastPlayQueueUpdate = playQueueUpdate;
this.playlist = items;
}
/**
* Send a Ready request once the local player begins playback. The
* server uses this to wait until every member is buffered before
* issuing the next Unpause.
*
* On timeout (player never starts), halt group playback so the rest
* of the group can proceed without us.
*/
scheduleReadyRequestOnPlaybackStart(apiClient: Api, origin: string): void {
waitForEventOnce(
this.manager,
"playbackstart",
WaitForEventDefaultTimeout,
["playbackerror"],
)
.then(() => {
console.debug(
"SyncPlay scheduleReadyRequestOnPlaybackStart: notifying server",
);
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localPause();
const currentPosition = playerWrapper.currentTime();
const currentPositionTicks = Math.round(
currentPosition * TicksPerMillisecond,
);
const isPlaying = playerWrapper.isPlaying();
const now = this.manager.getTimeSync().localDateToRemote(new Date());
try {
getSyncPlayApi(apiClient).syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: this.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay syncPlayReady failed", error);
}
})
.catch((error) => {
console.error(
"Timed out waiting for 'playbackstart' event!",
origin,
error,
);
if (!this.manager.isSyncPlayEnabled()) {
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
}
this.manager.haltGroupPlayback(apiClient);
});
}
/** Start local playback by navigating to the player screen for the current item. */
startPlayback(apiClient: Api): void {
if (!this.manager.isFollowingGroupPlayback()) {
console.debug("SyncPlay startPlayback: ignoring, not following playback");
return;
}
if (this.isPlaylistEmpty()) {
console.debug("SyncPlay startPlayback: empty playlist");
return;
}
// Estimate where to start playback from. Prefer the last playback
// command if newer than the queue update (playback ticks change
// more often than queue position).
const playbackCommand = this.manager.getLastPlaybackCommand();
let startPositionTicks = 0;
if (
playbackCommand &&
(
playbackCommand as unknown as { EmittedAt: Date }
).EmittedAt?.getTime() >= this.getLastUpdateTime()
) {
startPositionTicks = this.manager
.getPlaybackCore()
.estimateCurrentTicks(
playbackCommand.PositionTicks ?? 0,
(playbackCommand as unknown as { When: Date }).When,
);
} else {
startPositionTicks = this.manager
.getPlaybackCore()
.estimateCurrentTicks(
this.getStartPositionTicks(),
(this.getLastUpdate() ?? new Date()) as Date,
);
}
const serverId = apiClient.deviceInfo?.id ?? "";
this.scheduleReadyRequestOnPlaybackStart(apiClient, "startPlayback");
this.manager
.getPlayerWrapper()
.localPlay({
ids: this.getPlaylistAsItemIds(),
startPositionTicks,
startIndex: this.getCurrentPlaylistIndex(),
serverId,
})
.catch((error: unknown) => {
console.error("SyncPlay startPlayback: localPlay failed", error);
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
});
}
/** Navigate to a specific item in the queue. */
setCurrentPlaylistItem(apiClient: Api, playlistItemId: string | null): void {
if (!this.manager.isFollowingGroupPlayback()) {
console.debug(
"SyncPlay setCurrentPlaylistItem: ignoring, not following playback",
);
return;
}
this.scheduleReadyRequestOnPlaybackStart(
apiClient,
"setCurrentPlaylistItem",
);
this.manager.getPlayerWrapper().localSetCurrentPlaylistItem(playlistItemId);
}
// -- getters ---------------------------------------------------------------
getCurrentPlaylistIndex(): number {
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
}
getCurrentPlaylistItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex ?? -1;
if (index === -1) return null;
return this.playlist[index]?.PlaylistItemId ?? null;
}
getPlaylist(): BaseItemDto[] {
return this.playlist.slice(0);
}
isPlaylistEmpty(): boolean {
return this.playlist.length === 0;
}
getLastUpdate(): Date | null {
if (!this.lastPlayQueueUpdate) return null;
return this.lastPlayQueueUpdate.LastUpdate as unknown as Date;
}
getLastUpdateTime(): number {
if (!this.lastPlayQueueUpdate) return 0;
return (this.lastPlayQueueUpdate.LastUpdate as unknown as Date).getTime();
}
getStartPositionTicks(): number {
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
}
getPlaylistAsItemIds(): (string | undefined)[] {
if (!this.lastPlayQueueUpdate) return [];
return (this.lastPlayQueueUpdate.Playlist ?? []).map((q) => q.ItemId);
}
// -- teardown --------------------------------------------------------------
/** Clear cached playlist. Called on group disable so a re-join starts clean. */
clear(): void {
this.lastPlayQueueUpdate = null;
this.playlist = [];
}
destroy(): void {
this.clear();
this.removeAllListeners();
}
}
export default QueueCore;

View File

@@ -0,0 +1,220 @@
/**
* TimeSync — NTP-style time synchronisation with the Jellyfin server.
*
* Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer,
* TimeSyncCore}.js` — three classes that exist on web because the
* abstract layer supports syncing against other group members, not just
* the server. RN only syncs against the server, so it's one class.
*
* Algorithm: repeatedly time a round-trip request to `getUtcTime`,
* compute `offset = ((requestReceived - requestSent) + (responseSent -
* responseReceived)) / 2`, keep the minimum-delay measurement out of
* the last 8. This is the standard NTP outlier-rejection trick — the
* measurement with the shortest delay is the most accurate because
* less network jitter could have skewed the timestamps.
*
* Polling: greedy mode at 1s intervals for the first 3 pings to warm
* up the offset, then low-profile at 60s intervals for steady-state.
* `forceUpdate()` resets to greedy mode (called on group join).
*/
import type { Api } from "@jellyfin/sdk";
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
import { EventEmitter } from "../EventEmitter";
const NumberOfTrackedMeasurements = 8;
const PollingIntervalGreedy = 1000; // ms
const PollingIntervalLowProfile = 60000; // ms
const GreedyPingCount = 3;
class Measurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
constructor(
requestSent: Date,
requestReceived: Date,
responseSent: Date,
responseReceived: Date,
) {
this.requestSent = requestSent.getTime();
this.requestReceived = requestReceived.getTime();
this.responseSent = responseSent.getTime();
this.responseReceived = responseReceived.getTime();
}
/** Time offset (ms): positive means server clock is ahead of ours. */
getOffset(): number {
return (
(this.requestReceived -
this.requestSent +
(this.responseSent - this.responseReceived)) /
2
);
}
/** Round-trip delay (ms), excluding server processing. */
getDelay(): number {
return (
this.responseReceived -
this.requestSent -
(this.responseSent - this.requestReceived)
);
}
/** One-way ping (ms). */
getPing(): number {
return this.getDelay() / 2;
}
}
/**
* Tracks the offset between this client's clock and the Jellyfin server's
* clock, and exposes conversions between local and remote Dates.
*
* Listeners:
* - `"update"` (timeOffset: number, ping: number) — fires on every
* successful ping. Errors are logged but not emitted; consumers
* should treat absence of updates as transient.
*/
export class TimeSync extends EventEmitter {
private api: Api;
private pingStop = true;
private pollingInterval = PollingIntervalGreedy;
private poller: ReturnType<typeof setTimeout> | null = null;
private pings = 0;
private measurement: Measurement | null = null;
private measurements: Measurement[] = [];
constructor(api: Api) {
super();
this.api = api;
}
/** Called when the user switches Jellyfin servers. */
updateApiClient(api: Api): void {
this.api = api;
}
/** Whether we've completed at least one successful measurement. */
isReady(): boolean {
return !!this.measurement;
}
/** Current best-estimate time offset (ms). */
getTimeOffset(): number {
return this.measurement ? this.measurement.getOffset() : 0;
}
/** Current best-estimate one-way ping (ms). */
getPing(): number {
return this.measurement ? this.measurement.getPing() : 0;
}
/** Convert a server-time Date to local time. */
remoteDateToLocal(remote: Date): Date {
return new Date(remote.getTime() - this.getTimeOffset());
}
/** Convert a local Date to server time. */
localDateToRemote(local: Date): Date {
return new Date(local.getTime() + this.getTimeOffset());
}
/** Start polling. Idempotent. */
startPing(): void {
this.pingStop = false;
this.scheduleNextPing();
}
/** Stop polling. Idempotent. */
stopPing(): void {
this.pingStop = true;
if (this.poller) {
clearTimeout(this.poller);
this.poller = null;
}
}
/** Reset to greedy polling and force a fresh measurement immediately. */
forceUpdate(): void {
this.stopPing();
this.pollingInterval = PollingIntervalGreedy;
this.pings = 0;
this.startPing();
}
/** Drop all measurements. Used on group leave. */
resetMeasurements(): void {
this.measurement = null;
this.measurements = [];
}
/** Full teardown on provider unmount. */
destroy(): void {
this.stopPing();
this.resetMeasurements();
this.removeAllListeners();
}
private scheduleNextPing(): void {
if (this.poller || this.pingStop) return;
this.poller = setTimeout(() => {
this.poller = null;
this.requestPing()
.then((result) => this.onPingResponse(result))
.catch((error) => {
console.error("SyncPlay TimeSync: ping failed", error);
})
.finally(() => this.scheduleNextPing());
}, this.pollingInterval);
}
private async requestPing() {
const requestSent = new Date();
const response = await getTimeSyncApi(this.api).getUtcTime();
const responseReceived = new Date();
const data = response.data;
const requestReceived = new Date(data.RequestReceptionTime as string);
const responseSent = new Date(data.ResponseTransmissionTime as string);
return { requestSent, requestReceived, responseSent, responseReceived };
}
private onPingResponse(result: {
requestSent: Date;
requestReceived: Date;
responseSent: Date;
responseReceived: Date;
}): void {
const measurement = new Measurement(
result.requestSent,
result.requestReceived,
result.responseSent,
result.responseReceived,
);
this.measurements.push(measurement);
if (this.measurements.length > NumberOfTrackedMeasurements) {
this.measurements.shift();
}
// Outlier rejection: pick the measurement with the shortest delay.
const sorted = [...this.measurements].sort(
(a, b) => a.getDelay() - b.getDelay(),
);
this.measurement = sorted[0];
// Throttle once we've warmed up.
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
this.emit("update", this.getTimeOffset(), this.getPing());
}
}
export default TimeSync;

View File

@@ -0,0 +1,13 @@
/**
* SyncPlay — public exports.
*
* Only what external consumers (components, hooks, screens) need.
* Internal modules (PlaybackCore, QueueCore, TimeSync, PlayerWrapper,
* queueTranslation, EventEmitter, etc.) stay package-private.
*/
export { Controller as SyncPlayController } from "./Controller";
export { msToTicks, ticksToMs } from "./constants";
export { SyncPlayManager } from "./Manager";
export { SyncPlayProvider, useSyncPlay } from "./SyncPlayProvider";
export * from "./types";

View File

@@ -0,0 +1,58 @@
/**
* PendingPlaybackTracker — tracks an in-flight `Unpause` / `Pause` request
* that we've sent to the server but haven't seen echoed back via
* `SyncPlayCommand`.
*
* Drives three things:
* 1. Drop duplicate rapid taps
* 2. Provide an optimistic-UI hint for the in-flight state
* 3. Override "current play state" when deciding pause-vs-unpause
* for the next tap
*
* Auto-clears after `pendingPlaybackTimeoutMs` so a lost broadcast
* doesn't freeze the UI forever.
*/
import { SYNC_PLAY_TUNING } from "../types";
export class PendingPlaybackTracker {
private command: "Unpause" | "Pause" | null = null;
private timeout: ReturnType<typeof setTimeout> | null = null;
private onChange: ((cmd: "Unpause" | "Pause" | null) => void) | null = null;
setChangeHandler(
handler: ((cmd: "Unpause" | "Pause" | null) => void) | null,
): void {
this.onChange = handler;
}
get(): "Unpause" | "Pause" | null {
return this.command;
}
mark(command: "Unpause" | "Pause"): void {
this.command = command;
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
console.debug(
"SyncPlay PendingPlaybackTracker: timed out waiting for broadcast",
command,
);
this.command = null;
this.timeout = null;
this.onChange?.(null);
}, SYNC_PLAY_TUNING.pendingPlaybackTimeoutMs);
this.onChange?.(command);
}
clear(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.command !== null) {
this.command = null;
this.onChange?.(null);
}
}
}

View File

@@ -0,0 +1,87 @@
/**
* PlayerWrapper — adapter between jellyfin's tick-based playerWrapper API
* and our millisecond-based `PlayerControls`. Methods that have no RN
* analog (queue mutation hooks) delegate to provider-supplied handlers
* which navigate to the player screen.
*/
import { TicksPerMillisecond } from "../constants";
import type { PlayerControls } from "../types";
/** Options passed to `playerWrapper.localPlay` — provider navigates to the player screen. */
export interface LocalPlayOptions {
ids: (string | undefined)[];
startPositionTicks: number;
startIndex: number;
serverId?: string;
}
export class PlayerWrapper {
private controls: PlayerControls | null = null;
private localPlayHandler: ((options: LocalPlayOptions) => void) | null = null;
private setCurrentItemHandler:
| ((playlistItemId: string | null) => void)
| null = null;
/** Attach / detach the underlying player. */
bindToControls(controls: PlayerControls | null): void {
this.controls = controls;
}
/** Provider wires this to navigate to the player screen. */
setLocalPlayHandler(handler: ((options: LocalPlayOptions) => void) | null) {
this.localPlayHandler = handler;
}
/** Provider wires this to navigate to a different queue item. */
setLocalSetCurrentItemHandler(
handler: ((playlistItemId: string | null) => void) | null,
) {
this.setCurrentItemHandler = handler;
}
localUnpause(): void {
this.controls?.play();
}
localPause(): void {
this.controls?.pause();
}
/** Upstream takes ticks; RN's `seekTo` takes ms. */
localSeek(positionTicks: number): void {
this.controls?.seekTo(positionTicks / TicksPerMillisecond);
}
/** RN: pause instead of teardown — leaving the player screen is the navigator's job. */
localStop(): void {
this.controls?.pause();
}
/** Position in ms. */
currentTime(): number {
return this.controls?.getCurrentPosition() ?? 0;
}
isPlaying(): boolean {
return this.controls?.isPlaying() ?? false;
}
isPlaybackActive(): boolean {
return this.controls !== null;
}
/** RN never runs as a remote-managed player. */
isRemote(): boolean {
return false;
}
localPlay(options: LocalPlayOptions): Promise<void> {
this.localPlayHandler?.(options);
return Promise.resolve();
}
localSetCurrentPlaylistItem(playlistItemId: string | null): void {
this.setCurrentItemHandler?.(playlistItemId);
}
}

View File

@@ -0,0 +1,64 @@
/**
* bufferingDebouncer — wrap an `isBuffering: boolean → void` notify callback
* with three RN-only guards. Web gets these for free from HTML `waiting`/
* `canplay`; our `PlayerControls` exposes state (not events) and the React
* effect that polls it can fire many times per second.
*
* - **dedup**: drop redundant calls when state hasn't changed
* - **debounce buffering→true**: only escalate after the threshold;
* going back to ready cancels the pending escalation
* - **coalesce inflight**: serialize concurrent sends
*
* Returns `{ notify, dispose }`.
*/
import { SYNC_PLAY_TUNING } from "../types";
export function createBufferingDebouncer(
send: (isBuffering: boolean) => Promise<void>,
) {
let lastSent: boolean | null = null;
let inflight: Promise<void> | null = null;
let pendingTimeout: ReturnType<typeof setTimeout> | null = null;
const flush = async (isBuffering: boolean) => {
if (lastSent === isBuffering) return;
if (inflight) {
try {
await inflight;
} catch {
// ignore — used only for ordering
}
if (lastSent === isBuffering) return;
}
lastSent = isBuffering;
inflight = send(isBuffering).finally(() => {
inflight = null;
});
return inflight;
};
return {
notify(isBuffering: boolean): void {
if (pendingTimeout) {
clearTimeout(pendingTimeout);
pendingTimeout = null;
}
if (!isBuffering) {
// Ready always fires immediately.
void flush(false);
return;
}
pendingTimeout = setTimeout(() => {
pendingTimeout = null;
void flush(true);
}, SYNC_PLAY_TUNING.minBufferingThresholdMs);
},
dispose(): void {
if (pendingTimeout) {
clearTimeout(pendingTimeout);
pendingTimeout = null;
}
},
};
}

View File

@@ -0,0 +1,58 @@
/**
* reconcileToGroupOnAttach — estimate the group's current position from
* the last play/pause broadcast and seek the freshly-attached player
* there if drift exceeds the threshold.
*
* Web's player binds at group-join, so this race doesn't exist there.
* On RN the player mounts in a separate route after the join, so
* commands arrive before controls attach. Without this, the player
* resumes from its local position and is silently behind the group.
*/
import { TicksPerMillisecond } from "../constants";
import {
type PlayerControls,
type SendCommand,
SYNC_PLAY_TUNING,
} from "../types";
export function reconcileToGroupOnAttach(
controls: PlayerControls,
lastCommand: SendCommand | null,
localToRemote: (local: Date) => Date,
): void {
if (
!lastCommand ||
(lastCommand.Command !== "Unpause" && lastCommand.Command !== "Pause") ||
!lastCommand.When ||
lastCommand.PositionTicks == null
) {
return;
}
try {
const commandWhen = new Date(lastCommand.When);
let targetTicks = lastCommand.PositionTicks;
if (lastCommand.Command === "Unpause") {
const remoteNow = localToRemote(new Date());
targetTicks +=
(remoteNow.getTime() - commandWhen.getTime()) * TicksPerMillisecond;
}
const targetMs = Math.max(0, targetTicks / TicksPerMillisecond);
const currentMs = controls.getCurrentPosition();
if (
Math.abs(currentMs - targetMs) >
SYNC_PLAY_TUNING.positionReconcileThresholdMs
) {
console.log(
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
);
controls.seekTo(targetMs);
}
} catch (error) {
console.warn(
"SyncPlay: failed to estimate group position on attach",
error,
);
}
}

View File

@@ -0,0 +1,183 @@
/**
* queueTranslation — expand container items into a real playable queue.
*
* The server takes the queue we send via `syncPlaySetNewQueue` and
* rebroadcasts it verbatim to every group member. Sending a container
* ID (Series, Season, BoxSet, Playlist) means every receiver fails to
* open the player because they can't directly play a container. We must
* expand to real playable item IDs before sending the queue.
*
* Video-focused: music (MusicArtist/MusicGenre) and photo branches are
* intentionally omitted. Live TV (Program), Episode auto-advance, and
* folder expansion are preserved because they're the common video flows.
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getItemsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
export interface TranslateOptions {
ids?: string[];
shuffle?: boolean;
queryOptions?: Record<string, unknown>;
}
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
async function getCurrentUser(api: Api) {
const user = (await getUserApi(api).getCurrentUser()).data;
if (!user?.Id) {
throw new Error("SyncPlay queueTranslation: no authenticated user");
}
return user;
}
async function queryItems(
api: Api,
userId: string,
params: Record<string, unknown>,
): Promise<BaseItemDto[]> {
const res = await getItemsApi(api).getItems({
limit: 300,
fields: PLAYBACK_FIELDS as unknown as never,
excludeLocationTypes: ["Virtual"] as unknown as never,
enableTotalRecordCount: false,
collapseBoxSetItems: false,
...params,
userId,
});
return res.data.Items ?? [];
}
function fetchFolderChildren(
api: Api,
userId: string,
params: Record<string, unknown>,
): Promise<BaseItemDto[]> {
return queryItems(api, userId, {
filters: ["IsNotFolder"],
recursive: true,
...params,
});
}
/**
* Resolve item IDs into full `BaseItemDto`s.
*
* - single ID → `getItem` (cheap, no Items wrapper)
* - multi ID → `getItems` with playback defaults
*/
export async function getItemsForPlayback(
api: Api,
ids: string[],
): Promise<BaseItemDto[]> {
if (!ids.length) return [];
const userId = (await getCurrentUser(api)).Id as string;
if (ids.length === 1) {
const res = await getUserLibraryApi(api).getItem({
userId,
itemId: ids[0],
});
return res.data ? [res.data] : [];
}
return queryItems(api, userId, { ids });
}
/**
* Expand a "first item" into a real playable queue.
*
* - Program → channel items
* - Playlist → playlist children
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
* - single Episode (when `EnableNextEpisodeAutoPlay`) → remaining series episodes
* - everything else → passthrough (Movies, Audio, single Episode w/ autoplay off)
*
* Preserves the caller's `ids` order so the receiver sees the same
* queue order the sender intended.
*/
export async function translateItemsForPlayback(
api: Api,
items: BaseItemDto[],
options: TranslateOptions = {},
): Promise<BaseItemDto[]> {
if (!items.length) return [];
const workingItems =
items.length > 1 && options.ids
? [...items].sort(
(a, b) =>
(options.ids ?? []).indexOf(a.Id ?? "") -
(options.ids ?? []).indexOf(b.Id ?? ""),
)
: items;
const firstItem = workingItems[0];
if (firstItem.Type === "Program" && firstItem.ChannelId) {
return getItemsForPlayback(api, [firstItem.ChannelId]);
}
const user = await getCurrentUser(api);
const userId = user.Id as string;
if (firstItem.Type === "Playlist") {
return queryItems(api, userId, {
parentId: firstItem.Id,
sortBy: options.shuffle ? ["Random"] : undefined,
});
}
if (firstItem.IsFolder) {
// Series, Season, BoxSet, MusicAlbum, etc.
const sortBy = options.shuffle
? ["Random"]
: firstItem.Type === "BoxSet"
? ["SortName"]
: undefined;
return fetchFolderChildren(api, userId, {
parentId: firstItem.Id,
mediaTypes: ["Audio", "Video"],
sortBy,
...(options.queryOptions ?? {}),
});
}
if (firstItem.Type === "Episode" && workingItems.length === 1) {
// Single-episode auto-next: load all remaining episodes in the
// series, starting at this one. Gated on the user preference so we
// don't surprise users who disabled autoplay.
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
return workingItems;
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: firstItem.SeriesId,
userId,
isMissing: false,
fields: PLAYBACK_FIELDS as unknown as never,
// SDK omits `isVirtualUnaired` from typed request; server honours
// it. Cast keeps wire payload identical to jellyfin-web.
...({ isVirtualUnaired: false } as Record<string, unknown>),
} as Parameters<ReturnType<typeof getTvShowsApi>["getEpisodes"]>[0]);
const all = res.data.Items ?? [];
// Drop everything before firstItem; keep firstItem and everything
// after. Empty list if firstItem isn't in the series (shouldn't
// happen, but matches upstream's behaviour).
let foundItem = false;
return all.filter((e) => {
if (foundItem) return true;
if (e.Id === firstItem.Id) {
foundItem = true;
return true;
}
return false;
});
}
// Movies, Audio, single Episode w/ autoplay off, etc.
return workingItems;
}

View File

@@ -0,0 +1,94 @@
/**
* useSyncPlayWebSocket
*
* Hook that connects the SyncPlay manager to WebSocket messages.
* Listens for SyncPlayCommand and SyncPlayGroupUpdate messages.
*
* IMPORTANT: We subscribe directly to the WebSocket via `addEventListener`
* rather than reading WebSocketProvider's `lastMessage` state. That state
* only holds the most recent message, so when the server emits bursts
* after a join (GroupJoined + StateUpdate + UserJoined + PlayQueue, all
* within a few ms), React's batching causes earlier messages to be
* overwritten before our effect can read them — most notably the
* GroupJoined message, which left the joining client thinking it hadn't
* joined while other members already saw it as a participant.
*
* Listening on the raw socket guarantees we see every frame in order.
*/
import { useEffect } from "react";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { SyncPlayManager } from "../Manager";
import type { GroupUpdate, SendCommand } from "../types";
/**
* Hook to connect SyncPlay manager to WebSocket
*/
export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
const { ws } = useWebSocketContext();
useEffect(() => {
if (!ws || !manager) return;
const handleMessage = (event: WebSocketMessageEvent) => {
let parsed: { MessageType?: string; Data?: unknown };
try {
parsed = JSON.parse(event.data as string);
} catch (error) {
console.error("SyncPlay: failed to parse WebSocket message", error);
return;
}
const { MessageType, Data } = parsed;
// Only handle SyncPlay messages here; everything else is handled
// elsewhere via WebSocketProvider's lastMessage.
if (!MessageType?.startsWith("SyncPlay")) return;
console.log(
`SyncPlay WebSocket [${MessageType}]:`,
JSON.stringify(Data).substring(0, 300),
);
switch (MessageType) {
case "SyncPlayCommand": {
const command = Data as SendCommand;
console.log(
`SyncPlay: COMMAND received - ${command.Command} at ${command.When}`,
command.Command === "Seek"
? `position=${command.PositionTicks}`
: "",
);
// Note: it's normal for controls to be missing here during the
// join → navigate → load window. Manager stashes the command and
// replays it on attach.
manager.processCommand(command);
break;
}
case "SyncPlayGroupUpdate": {
// SDK's `GroupUpdate` type is a discriminated union with a
// narrower `Type` enum than the wire format. Cast through
// unknown so upstream `Manager.processGroupUpdate` can switch
// on the real string.
const update = Data as unknown as GroupUpdate;
console.debug(
"SyncPlay: group update -",
(update as { Type?: string }).Type,
);
manager.processGroupUpdate(update);
break;
}
default:
break;
}
};
ws.addEventListener("message", handleMessage);
return () => {
ws.removeEventListener("message", handleMessage);
};
}, [ws, manager]);
}

View File

@@ -0,0 +1,88 @@
/**
* SyncPlay — public types and tuning constants.
*
* Re-exports the SDK types we use, defines the small RN-specific
* extensions (PlayerControls, OSD actions), and centralises the magic
* numbers that govern sync behaviour.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
// SDK type re-exports — kept narrow on purpose, only what callers
// actually reach for.
export type {
GroupInfoDto,
GroupQueueMode,
GroupRepeatMode,
GroupShuffleMode,
GroupStateType,
GroupUpdate,
PlayQueueUpdate,
PlayQueueUpdateReason,
SendCommand,
SendCommandType,
SyncPlayQueueItem,
SyncPlayUserAccessType,
} from "@jellyfin/sdk/lib/generated-client/models";
/** Jellyfin's tick unit. 1ms = 10000 ticks. */
export const TicksPerMillisecond = 10000;
/**
* Player controls SyncPlay drives. The provider wires this up against
* the active RN player (mpv / VLC / expo-video).
*/
export interface PlayerControls {
play: () => void;
pause: () => void;
/** Seek to absolute position in milliseconds. */
seekTo: (positionMs: number) => void;
setSpeed: (speed: number) => void;
getSpeed: () => number;
/** Current position in milliseconds. */
getCurrentPosition: () => number;
isPlaying: () => boolean;
isBuffering: () => boolean;
}
/** OSD action types — drive optional player-overlay feedback. */
export type SyncPlayOsdAction =
/** transient — 1.5s pulse, the unpause command fired locally */
| "unpause"
/** transient — 1.5s pulse, the pause command fired locally */
| "pause"
/** transient — 1.5s pulse, a seek command applied locally */
| "seek"
/** persistent — group is about to play (Waiting+Unpause / pending Unpause) */
| "schedule-play"
/** persistent — another client is buffering (Waiting+Buffer) */
| "buffering"
/** persistent — group transitioning to pause (Waiting+Pause) */
| "wait-pause"
/** persistent — group transitioning to unpause; sibling of schedule-play */
| "wait-unpause";
/**
* Tuning constants. These mirror jellyfin-web's defaults; tweak with
* care — they affect perceived sync quality across all clients.
*/
export const SYNC_PLAY_TUNING = {
/** Drift threshold (ms) above which we hard-seek to catch up. */
minDelaySkipToSync: 400,
/** Drift beyond this (ms) is always corrected by seeking. */
maxDelaySync: 3000,
/** Don't escalate buffering to the group for blips shorter than this (ms). */
minBufferingThresholdMs: 3000,
/** Player-attach drift (ms) above which we reconcile to group position. */
positionReconcileThresholdMs: 500,
/** Safety timeout (ms) for in-flight Pause/Unpause optimistic UI. */
pendingPlaybackTimeoutMs: 1500,
} as const;
/** Options accepted by `Controller.play`. */
export interface PlayOptions {
ids?: string[];
items?: BaseItemDto[];
startIndex?: number;
startPositionTicks?: number;
}

View File

@@ -13,7 +13,6 @@ import {
import { AppState, type AppStateStatus } from "react-native"; import { AppState, type AppStateStatus } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useRemoteControl } from "@/hooks/useRemoteControl";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
@@ -45,6 +44,15 @@ interface WebSocketContextType {
lastMessage: WebSocketMessage | null; lastMessage: WebSocketMessage | null;
sendMessage: (message: any) => void; sendMessage: (message: any) => void;
clearLastMessage: () => void; clearLastMessage: () => void;
/**
* Acquire a keep-alive token. While at least one token is held the
* WebSocket will NOT be closed on AppState background/inactive. Used
* by the video player while in Picture-in-Picture so SyncPlay (and
* any other server-pushed events) keep flowing. Returns a release
* function — call it (or rely on the React effect cleanup) when the
* keep-alive is no longer needed.
*/
acquireKeepAlive: () => () => void;
} }
const WebSocketContext = createContext<WebSocketContextType | null>(null); const WebSocketContext = createContext<WebSocketContextType | null>(null);
@@ -55,8 +63,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState<WebSocket | null>(null); const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null); const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
// Route Jellyfin remote-control messages to the active player.
useRemoteControl(lastMessage);
const router = useRouter(); const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => { const deviceId = useMemo(() => {
@@ -66,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>( const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
); );
// Ref-counted keep-alive: while > 0 we skip the AppState→background
// close so the socket survives PiP / brief OS suspensions. iOS keeps
// the audio session (and therefore networking) alive while PiP is
// active, so the WS can continue to receive SyncPlay commands.
const keepAliveCountRef = useRef(0);
const acquireKeepAlive = useCallback((): (() => void) => {
keepAliveCountRef.current += 1;
let released = false;
return () => {
if (released) return;
released = true;
keepAliveCountRef.current = Math.max(0, keepAliveCountRef.current - 1);
};
}, []);
const connectWebSocket = useCallback(() => { const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken || !isNetworkConnected) { if (!deviceId || !api?.accessToken || !isNetworkConnected) {
@@ -222,14 +243,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
IconUrl: IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png", "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"], PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: [ SupportedCommands: ["Play"],
"Play",
"DisplayMessage",
"SetVolume",
"ToggleMute",
"Mute",
"Unmute",
],
SupportsMediaControl: true, SupportsMediaControl: true,
SupportsPersistentIdentifier: true, SupportsPersistentIdentifier: true,
}, },
@@ -245,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
useEffect(() => { useEffect(() => {
const handleAppStateChange = (state: AppStateStatus) => { const handleAppStateChange = (state: AppStateStatus) => {
if (state === "background" || state === "inactive") { if (state === "background" || state === "inactive") {
if (keepAliveCountRef.current > 0) {
console.log(
`App backgrounded but WS keep-alive held (${keepAliveCountRef.current}); leaving socket open`,
);
return;
}
console.log("App moving to background, closing WebSocket..."); console.log("App moving to background, closing WebSocket...");
ws?.close(); ws?.close();
} else if (state === "active") { } else if (state === "active") {
// Only reconnect if we actually lost the socket (we may have
// skipped the close above because of a keep-alive token).
if (ws?.readyState === WebSocket.OPEN) {
return;
}
console.log("App coming to foreground, reconnecting WebSocket..."); console.log("App coming to foreground, reconnecting WebSocket...");
connectWebSocket(); connectWebSocket();
} }
@@ -277,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []); }, []);
return ( return (
<WebSocketContext.Provider <WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }} value={{
ws,
isConnected,
lastMessage,
sendMessage,
clearLastMessage,
acquireKeepAlive,
}}
> >
{children} {children}
</WebSocketContext.Provider> </WebSocketContext.Provider>

View File

@@ -27,112 +27,6 @@
"too_old_server_text": "Unsupported Jellyfin Server Discovered", "too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version" "too_old_server_description": "Please update Jellyfin to the latest version"
}, },
"player": {
"skip_intro": "Skip Intro",
"live": "LIVE",
"mpv_player_title": "MPV Player",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded",
"skip_outro": "Skip Outro",
"skip_recap": "Skip Recap",
"skip_commercial": "Skip Commercial",
"skip_preview": "Skip Preview",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"up_next": "Up next",
"next_episode_in": "Next episode in {{seconds}}s",
"play_now": "Play now",
"cancel": "Cancel"
},
"casting_player": {
"buffering": "Buffering...",
"changing_audio": "Changing audio...",
"changing_subtitles": "Changing subtitles...",
"season_episode_format": "Season {{season}} • Episode {{episode}}",
"connecting": "Connecting to Chromecast...",
"unknown_device": "Unknown Device",
"ending_at": "Ending at {{time}}",
"unknown": "Unknown",
"connected": "Connected",
"volume": "Volume",
"muted": "Muted",
"disconnect": "Disconnect",
"stop_casting": "Stop Casting",
"disconnecting": "Disconnecting...",
"chromecast": "Chromecast",
"device_name": "Device Name",
"playback_settings": "Playback Settings",
"version": "Version",
"stop": "Stop",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitles",
"none": "None",
"playback_speed": "Playback Speed",
"normal": "Normal",
"episodes": "Episodes",
"season": "Season {{number}}",
"minutes_short": "min",
"episode_label": "Episode {{number}}",
"forced": "Forced",
"device": "Device",
"cancel": "Cancel",
"connection_quality": {
"excellent": "Excellent",
"good": "Good",
"fair": "Fair",
"poor": "Poor",
"disconnected": "Disconnected"
},
"error_title": "Chromecast Error",
"error_description": "Something went wrong with the cast session",
"retry": "Try Again",
"critical_error_title": "Multiple Errors Detected",
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
"track_changed": "Track changed successfully",
"audio_track_changed": "Audio track changed",
"subtitle_track_changed": "Subtitle track changed",
"seeking": "Seeking...",
"seeking_error": "Failed to seek",
"load_failed": "Failed to load media",
"load_retry": "Retrying media load..."
},
"server": { "server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com", "server_url_placeholder": "http(s)://your-server.com",
@@ -471,23 +365,6 @@
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode", "auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count", "max_auto_play_episode_count": "Max Auto Play Episode Count",
"segment_skip_settings": "Segment Skip Settings",
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
"skip_intro": "Skip Intro",
"skip_intro_description": "Action when intro segment is detected",
"skip_outro": "Skip Outro/Credits",
"skip_outro_description": "Action when outro/credits segment is detected",
"skip_recap": "Skip Recap",
"skip_recap_description": "Action when recap segment is detected",
"skip_commercial": "Skip Commercial",
"skip_commercial_description": "Action when commercial segment is detected",
"skip_preview": "Skip Preview",
"skip_preview_description": "Action when preview segment is detected",
"segment_skip_none": "None",
"segment_skip_ask": "Show Skip Button",
"segment_skip_auto": "Auto Skip",
"autoplay_countdown_seconds": "Player countdown (seconds)",
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
"disabled": "Disabled" "disabled": "Disabled"
}, },
"downloads": { "downloads": {
@@ -804,6 +681,50 @@
"custom_links": { "custom_links": {
"no_links": "No Links" "no_links": "No Links"
}, },
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": { "chapters": {
"title": "Chapters", "title": "Chapters",
"chapter_number": "Chapter {{number}}", "chapter_number": "Chapter {{number}}",
@@ -1082,6 +1003,28 @@
"all": "All media (default)" "all": "All media (default)"
} }
}, },
"syncplay": {
"title": "SyncPlay",
"my_group": "My Group",
"join_group": "Join Group",
"leave_group": "Leave Group",
"create_new_group": "Create New Group",
"available_groups": "Available Groups",
"members": "members",
"failed_to_start": "Failed to start SyncPlay group playback",
"resume_playback": "Resume playback",
"toasts": {
"MessageSyncPlayGroupJoined": "Joined group",
"MessageSyncPlayGroupLeft": "Left group",
"MessageSyncPlayUserJoined": "{{user}} joined the group",
"MessageSyncPlayUserLeft": "{{user}} left the group",
"MessageSyncPlayCreateGroupDenied": "Permission denied to create a group",
"MessageSyncPlayJoinGroupDenied": "Permission denied to join group",
"MessageSyncPlayLibraryAccessDenied": "Access to the content has been denied",
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist",
"MessageSyncPlayErrorMedia": "SyncPlay error during media playback"
}
},
"companion_login": { "companion_login": {
"title": "Pair with TV", "title": "Pair with TV",
"align_qr": "Align the QR code within the frame", "align_qr": "Align the QR code within the frame",

View File

@@ -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);

View File

@@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo } from "react";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
import { writeInfoLog } from "@/utils/log"; import { writeInfoLog } from "@/utils/log";
import { storage } from "../mmkv"; import { storage } from "../mmkv";
@@ -176,9 +175,6 @@ export enum VideoPlayer {
MPV = 0, MPV = 0,
} }
// Segment skip behavior options
export type SegmentSkipMode = "none" | "ask" | "auto";
// TV Typography scale presets // TV Typography scale presets
export enum TVTypographyScale { export enum TVTypographyScale {
Small = "small", Small = "small",
@@ -246,23 +242,10 @@ export type Settings = {
jellyseerrServerUrl?: string; jellyseerrServerUrl?: string;
useKefinTweaks: boolean; useKefinTweaks: boolean;
hiddenLibraries?: string[]; hiddenLibraries?: string[];
/** Chromecast profile selection mode. "auto" detects per device. */ enableH265ForChromecast: boolean;
chromecastProfile: ChromecastProfileMode;
/** Optional manual Chromecast video bitrate cap, in bits per second. */
chromecastMaxBitrate?: number;
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount; maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number; autoPlayEpisodeCount: number;
autoPlayNextEpisode: boolean; autoPlayNextEpisode: boolean;
// Media segment skip preferences
skipIntro: SegmentSkipMode;
skipOutro: SegmentSkipMode;
skipRecap: SegmentSkipMode;
skipCommercial: SegmentSkipMode;
skipPreview: SegmentSkipMode;
/** Native player next-episode countdown, in seconds. */
autoplayCountdownSeconds: number;
/** Chromecast next-episode countdown, in seconds. */
castAutoplayCountdownSeconds: number;
// Playback speed settings // Playback speed settings
defaultPlaybackSpeed: number; defaultPlaybackSpeed: number;
playbackSpeedPerMedia: Record<string, number>; playbackSpeedPerMedia: Record<string, number>;
@@ -362,19 +345,10 @@ export const defaultValues: Settings = {
jellyseerrServerUrl: undefined, jellyseerrServerUrl: undefined,
useKefinTweaks: false, useKefinTweaks: false,
hiddenLibraries: [], hiddenLibraries: [],
chromecastProfile: "auto", enableH265ForChromecast: false,
chromecastMaxBitrate: undefined,
maxAutoPlayEpisodeCount: { key: "3", value: 3 }, maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0, autoPlayEpisodeCount: 0,
autoPlayNextEpisode: true, autoPlayNextEpisode: true,
// Media segment skip defaults
skipIntro: "ask",
skipOutro: "ask",
skipRecap: "ask",
skipCommercial: "ask",
skipPreview: "ask",
autoplayCountdownSeconds: 15,
castAutoplayCountdownSeconds: 30,
// Playback speed defaults // Playback speed defaults
defaultPlaybackSpeed: 1.0, defaultPlaybackSpeed: 1.0,
playbackSpeedPerMedia: {}, playbackSpeedPerMedia: {},

View File

@@ -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();
});
});

View File

@@ -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" }],
};
};

View File

@@ -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,
);
});
});

View File

@@ -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;
};

View File

@@ -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);
});
});

View File

@@ -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");
};

View File

@@ -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 };
}
}
};

Some files were not shown because too many files have changed in this diff Show More