mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 09:23:07 +01:00
Compare commits
17 Commits
refactor-c
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613ad1effc | ||
|
|
2df63eb63c | ||
|
|
ab42e8a576 | ||
|
|
0e93cd5385 | ||
|
|
96b4121c1f | ||
|
|
f7033e7abb | ||
|
|
0d796d01b8 | ||
|
|
46d96d5965 | ||
|
|
7d16e7d5c7 | ||
|
|
ceb9b5a1ae | ||
|
|
1144ff5049 | ||
|
|
4d508a4315 | ||
|
|
915a4febbb | ||
|
|
88163eb531 | ||
|
|
27c400a54a | ||
|
|
261f7cc0cd | ||
|
|
d06daef933 |
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
|
||||
## Code Quality Standards
|
||||
## Coding Standards
|
||||
|
||||
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||
|
||||
### Type Safety
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
||||
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
||||
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
||||
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
||||
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
||||
- Enable and respect strict TypeScript compiler options
|
||||
- Define explicit return types for functions
|
||||
- Use discriminated unions for complex state
|
||||
|
||||
### Code Reliability
|
||||
- Implement comprehensive error handling with try-catch blocks
|
||||
- Validate all external inputs (API responses, user input, query params)
|
||||
- Handle edge cases explicitly (empty arrays, null, undefined)
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
||||
- Add runtime checks for critical operations
|
||||
- Implement proper loading and error states in components
|
||||
|
||||
### Best Practices
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization when needed
|
||||
- Use React.memo() for performance optimization
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Write self-documenting code with clear intent
|
||||
- Add comments only when code complexity requires explanation
|
||||
|
||||
## API Integration
|
||||
|
||||
@@ -108,18 +85,6 @@ Exemples:
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
||||
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
||||
- **NEVER add or remove keys** - Crowdin manages the key structure
|
||||
- **Editing translation values is safe** - Bidirectional sync handles merges
|
||||
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
||||
- **Crowdin workflow**:
|
||||
- New keys added to `en.json` sync to Crowdin automatically
|
||||
- Approved translations sync back to language files via GitHub integration
|
||||
- The source of truth is `en.json` for structure, Crowdin for translations
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
|
||||
@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
const SyncPlayButtonComponent = Platform.isTV
|
||||
? null
|
||||
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
@@ -33,6 +36,7 @@ export default function IndexLayout() {
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Factory function to create skip options for a specific segment type
|
||||
* Reduces code duplication across all 5 segment types
|
||||
*/
|
||||
const useSkipOptions = (
|
||||
settingKey:
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview",
|
||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
||||
t: TFunction<"translation", undefined>,
|
||||
) => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
selected: option.value === settings?.[settingKey],
|
||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
||||
);
|
||||
};
|
||||
|
||||
export default function SegmentSkipPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.other.segment_skip_settings"),
|
||||
});
|
||||
}, [navigation, t]);
|
||||
|
||||
const skipIntroOptions = useSkipOptions(
|
||||
"skipIntro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipOutroOptions = useSkipOptions(
|
||||
"skipOutro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipRecapOptions = useSkipOptions(
|
||||
"skipRecap",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipCommercialOptions = useSkipOptions(
|
||||
"skipCommercial",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipPreviewOptions = useSkipOptions(
|
||||
"skipPreview",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={false} className='px-4'>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
subtitle={t("home.settings.other.skip_intro_description")}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipIntroOptions}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
subtitle={t("home.settings.other.skip_outro_description")}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipOutroOptions}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
subtitle={t("home.settings.other.skip_recap_description")}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipRecapOptions}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipCommercialOptions}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
subtitle={t("home.settings.other.skip_preview_description")}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipPreviewOptions}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
|
||||
const SEGMENT_SKIP_OPTIONS = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): Array<{
|
||||
label: string;
|
||||
value: "none" | "ask" | "auto";
|
||||
}> => [
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_auto"),
|
||||
value: "auto",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_ask"),
|
||||
value: "ask",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_none"),
|
||||
value: "none",
|
||||
},
|
||||
];
|
||||
@@ -40,6 +40,8 @@ const Layout = () => {
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarActiveTintColor: "#FFFFFF",
|
||||
tabBarInactiveTintColor: "#9CA3AF",
|
||||
tabBarLabelStyle: {
|
||||
fontSize: TAB_LABEL_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
|
||||
@@ -11,8 +11,6 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
|
||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -141,8 +139,6 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<CastingMiniPlayer />
|
||||
<CastAutoplayWatcher />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
|
||||
@@ -1,768 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Player Modal
|
||||
* Protocol-agnostic full-screen player for all supported casting protocols
|
||||
*/
|
||||
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastState,
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
||||
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
|
||||
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
|
||||
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
|
||||
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
|
||||
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
||||
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
||||
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
||||
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
||||
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
||||
import { useCasting } from "@/hooks/useCasting";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
||||
import { useCastSelection } from "@/hooks/useCastSelection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { detectCapabilities } from "@/utils/casting/capabilities";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { getPosterUrl } from "@/utils/casting/helpers";
|
||||
import { resolveSelection } from "@/utils/casting/selection";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
export default function CastingPlayerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Chromecast autoplay countdown — watcher hook drives this atom; we render
|
||||
// the overlay here when set, and handle Play-now / Cancel from the user.
|
||||
const castAutoplay = useAtomValue(castAutoplayAtom);
|
||||
const setCastAutoplay = useSetAtom(castAutoplayAtom);
|
||||
|
||||
// Get raw Chromecast state directly - same as old implementation
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const castDevice = useCastDevice();
|
||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
||||
useRemoteMediaClient();
|
||||
|
||||
// Fetch full item data from Jellyfin by ID and derive the effective item
|
||||
const { fetchedItem, currentItem } = useCastPlayerItem({
|
||||
api,
|
||||
user,
|
||||
mediaStatus,
|
||||
});
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentDevice = castDevice?.friendlyName ?? null;
|
||||
|
||||
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
||||
// live-progress interpolation, resume-position tracking, trickplay preview.
|
||||
const {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
||||
|
||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
||||
const castingControls = useCasting(currentItem);
|
||||
const {
|
||||
togglePlayPause,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setVolume,
|
||||
volume,
|
||||
remoteMediaClient,
|
||||
} = currentItem
|
||||
? castingControls
|
||||
: {
|
||||
togglePlayPause: async () => {},
|
||||
skipForward: async () => {},
|
||||
skipBackward: async () => {},
|
||||
setVolume: () => {},
|
||||
volume: 1,
|
||||
remoteMediaClient: null,
|
||||
};
|
||||
|
||||
// Modal states
|
||||
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
||||
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Chapter markers (shown for both episodes and movies).
|
||||
const chapters = currentItem?.Chapters;
|
||||
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
|
||||
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
|
||||
|
||||
// Reload the cast stream with a full selection; resolves true on success.
|
||||
const reloadWithSelection = useCallback(
|
||||
async (selection: CastSelection): Promise<boolean> => {
|
||||
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
||||
console.warn("[Casting Player] Cannot reload - missing required data");
|
||||
return false;
|
||||
}
|
||||
const currentPosition = resumePositionRef.current;
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: currentItem,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: {
|
||||
mediaSourceId: selection.mediaSourceId,
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
maxBitrate: selection.maxBitrate,
|
||||
startPositionMs: currentPosition * 1000,
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to reload stream:",
|
||||
result.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
],
|
||||
);
|
||||
|
||||
const { currentSelection, applySelection } = useCastSelection({
|
||||
currentItem,
|
||||
mediaStatus,
|
||||
reload: reloadWithSelection,
|
||||
});
|
||||
|
||||
// Episode/season cluster: episode list, next episode, season data, loader
|
||||
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
|
||||
useCastEpisodes({
|
||||
api,
|
||||
user,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings,
|
||||
});
|
||||
|
||||
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
|
||||
// cast customData) still describes the previous episode. Used to suppress
|
||||
// episode-dependent secondary UI that would otherwise flash stale data.
|
||||
const isEpisodeTransitioning =
|
||||
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
|
||||
|
||||
// Expose this player to the app-wide remote-control surface while a cast
|
||||
// session is connected. The individual useCasting methods are each
|
||||
// useCallback-wrapped and stable, so depend on them directly rather than on
|
||||
// the whole `castingControls` object literal (rebuilt every render).
|
||||
const {
|
||||
togglePlayPause: castTogglePlayPause,
|
||||
pause: castPause,
|
||||
play: castPlay,
|
||||
stop: castStop,
|
||||
seek: castSeek,
|
||||
setVolume: castSetVolume,
|
||||
} = castingControls;
|
||||
// toggleMute reads the latest volume without making `volume` a useMemo dep.
|
||||
const volumeRef = useRef(volume);
|
||||
volumeRef.current = volume;
|
||||
|
||||
const castController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
playPause: () => {
|
||||
castTogglePlayPause();
|
||||
},
|
||||
pause: () => {
|
||||
castPause();
|
||||
},
|
||||
unpause: () => {
|
||||
castPlay();
|
||||
},
|
||||
stop: () => {
|
||||
castStop();
|
||||
},
|
||||
seek: (positionMs) => {
|
||||
castSeek(positionMs);
|
||||
},
|
||||
next: () => {
|
||||
if (nextEpisode) loadEpisode(nextEpisode);
|
||||
},
|
||||
previous: () => {
|
||||
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
|
||||
if (idx > 0) loadEpisode(episodes[idx - 1]);
|
||||
},
|
||||
setVolume: (level) => {
|
||||
castSetVolume(level);
|
||||
},
|
||||
toggleMute: () => {
|
||||
castSetVolume(volumeRef.current > 0 ? 0 : 1);
|
||||
},
|
||||
}),
|
||||
[
|
||||
castTogglePlayPause,
|
||||
castPause,
|
||||
castPlay,
|
||||
castStop,
|
||||
castSeek,
|
||||
castSetVolume,
|
||||
episodes,
|
||||
nextEpisode,
|
||||
loadEpisode,
|
||||
currentItem?.Id,
|
||||
],
|
||||
);
|
||||
|
||||
useRegisterPlaybackController(
|
||||
castController,
|
||||
castState === CastState.CONNECTED,
|
||||
);
|
||||
|
||||
// The MediaSource currently selected, for deriving its tracks.
|
||||
// Derived from fetchedItem: the slim cast-customData item strips per-source
|
||||
// MediaStreams, so only the full fetched item yields correct track lists.
|
||||
const selectedSource = useMemo(
|
||||
() =>
|
||||
fetchedItem?.MediaSources?.find(
|
||||
(s) => s.Id === currentSelection?.mediaSourceId,
|
||||
) ??
|
||||
fetchedItem?.MediaSources?.[0] ??
|
||||
null,
|
||||
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
|
||||
);
|
||||
|
||||
// Real alternate versions (multi-version items).
|
||||
const availableVersions = useMemo(
|
||||
() =>
|
||||
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
|
||||
id: s.Id ?? `source-${i}`,
|
||||
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
|
||||
})),
|
||||
[fetchedItem?.MediaSources, t],
|
||||
);
|
||||
|
||||
// Quality tiers from the shared ladder, capped to BOTH the device's
|
||||
// capability and the media's own bitrate — a tier above either ceiling
|
||||
// would behave identically to "Max", so it is not offered.
|
||||
const availableQualities = useMemo(() => {
|
||||
const caps = detectCapabilities(castDevice, {
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrate: settings.chromecastMaxBitrate,
|
||||
});
|
||||
const mediaBitrate =
|
||||
selectedSource?.Bitrate ??
|
||||
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
|
||||
Number.POSITIVE_INFINITY;
|
||||
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
|
||||
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
|
||||
}, [
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
selectedSource,
|
||||
fetchedItem?.MediaStreams,
|
||||
]);
|
||||
|
||||
const availableAudioTracks = useMemo(() => {
|
||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||
if (!streams) return [];
|
||||
return streams
|
||||
.filter((stream) => stream.Type === "Audio")
|
||||
.map((stream) => ({
|
||||
index: stream.Index ?? 0,
|
||||
language: stream.Language || "Unknown",
|
||||
displayTitle:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
||||
codec: stream.Codec || "Unknown",
|
||||
channels: stream.Channels,
|
||||
bitrate: stream.BitRate,
|
||||
}));
|
||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||
|
||||
const availableSubtitleTracks = useMemo(() => {
|
||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||
if (!streams) return [];
|
||||
return streams
|
||||
.filter((stream) => stream.Type === "Subtitle")
|
||||
.map((stream) => ({
|
||||
index: stream.Index ?? 0,
|
||||
language: stream.Language || "Unknown",
|
||||
displayTitle:
|
||||
stream.DisplayTitle ||
|
||||
[
|
||||
stream.Language || "Unknown",
|
||||
stream.IsForced ? " (Forced)" : "",
|
||||
stream.Title ? ` - ${stream.Title}` : "",
|
||||
].join(""),
|
||||
codec: stream.Codec || "Unknown",
|
||||
isForced: stream.IsForced || false,
|
||||
isExternal: stream.IsExternal || false,
|
||||
}));
|
||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||
|
||||
// Autoplay overlay's "Play now" — load the queued next episode immediately.
|
||||
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
|
||||
// same start-position derivation) so the cast load is identical regardless
|
||||
// of whether it is triggered by the user or by the countdown timer.
|
||||
const onAutoplayPlayNow = useCallback(async () => {
|
||||
if (!castAutoplay) return;
|
||||
const episode = castAutoplay.nextEpisode;
|
||||
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
|
||||
setCastAutoplay(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: episode,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load next episode (play now):",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reset the autoplay counter on explicit user action.
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load next episode (play now):",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setCastAutoplay(null);
|
||||
}
|
||||
}, [
|
||||
castAutoplay,
|
||||
api,
|
||||
user?.Id,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
updateSettings,
|
||||
setCastAutoplay,
|
||||
]);
|
||||
|
||||
// Poster URL for the queued next episode (mirrors `posterUrl` for the
|
||||
// currently-playing item — same helper, same dimensions).
|
||||
const autoplayPosterUrl = useMemo(() => {
|
||||
if (!castAutoplay || !api?.basePath) return null;
|
||||
const ep = castAutoplay.nextEpisode;
|
||||
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
|
||||
// call the helper with `undefined`. AutoplayCountdown handles null.
|
||||
if (!ep?.Id) return null;
|
||||
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
|
||||
}, [castAutoplay, api?.basePath]);
|
||||
|
||||
// NOTE: Auto-navigation to casting-player is handled by higher-level
|
||||
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
|
||||
// do NOT call router.replace("/casting-player") here because this component
|
||||
// IS the casting-player screen — doing so would cause redundant navigation loops.
|
||||
|
||||
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
||||
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
||||
useChromecastSegments(currentItem, progress * 1000, false);
|
||||
|
||||
// Swipe down to dismiss gesture
|
||||
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
|
||||
router,
|
||||
});
|
||||
|
||||
// Memoize expensive calculations (before early return)
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api?.basePath || !currentItem?.Id) return null;
|
||||
|
||||
// For episodes, use SEASON poster instead of episode poster
|
||||
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
||||
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
||||
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
||||
const seasonImageTag = seasonData.ImageTags?.Primary;
|
||||
return seasonImageTag
|
||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
||||
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
||||
}
|
||||
|
||||
// Fallback to item poster for non-episodes or if season data not loaded
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
260,
|
||||
390,
|
||||
);
|
||||
}, [
|
||||
api?.basePath,
|
||||
currentItem?.Id,
|
||||
currentItem?.Type,
|
||||
seasonData?.Id,
|
||||
seasonData?.ImageTags?.Primary,
|
||||
currentItem?.ImageTags?.Primary,
|
||||
]);
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
|
||||
// Redirect if not connected - check CastState like old implementation
|
||||
useEffect(() => {
|
||||
// Redirect immediately when disconnected or no devices
|
||||
if (
|
||||
castState === CastState.NOT_CONNECTED ||
|
||||
castState === CastState.NO_DEVICES_AVAILABLE
|
||||
) {
|
||||
// Use setTimeout to avoid state update during render
|
||||
const timer = setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, router]);
|
||||
|
||||
// Also redirect if mediaStatus disappears (media ended or stopped)
|
||||
useEffect(() => {
|
||||
if (castState === CastState.CONNECTED && !mediaStatus) {
|
||||
const timer = setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 500); // Small delay to allow for media transitions
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, mediaStatus, router]);
|
||||
|
||||
// Show loading while connecting
|
||||
if (castState === CastState.CONNECTING) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={{ color: "#fff", marginTop: 16 }}>
|
||||
{t("casting_player.connecting")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if not connected or no media playing
|
||||
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "fullScreenModal",
|
||||
animation: "slide_from_bottom",
|
||||
}}
|
||||
/>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{/* Header - Fixed at top */}
|
||||
<CastPlayerHeader
|
||||
insetTop={insets.top}
|
||||
protocolColor={protocolColor}
|
||||
currentDevice={currentDevice}
|
||||
t={t}
|
||||
onDismiss={dismissModal}
|
||||
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
|
||||
onPressSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
|
||||
{/* Title Area — hidden during an episode change to avoid flashing
|
||||
the previous episode's title/season-episode numbers. */}
|
||||
{!isEpisodeTransitioning && (
|
||||
<CastPlayerTitle
|
||||
insetTop={insets.top}
|
||||
currentItem={currentItem}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: insets.top + 160,
|
||||
paddingBottom: insets.bottom + 500,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Poster with buffering overlay — force the overlay during an
|
||||
episode change so the loading state covers the stale poster. */}
|
||||
<CastPlayerPoster
|
||||
posterUrl={posterUrl}
|
||||
isBuffering={isBuffering || isEpisodeTransitioning}
|
||||
currentSegment={currentSegment}
|
||||
skipIntro={skipIntro}
|
||||
skipCredits={skipCredits}
|
||||
skipSegment={skipSegment}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
mediaStatus={mediaStatus}
|
||||
protocolColor={protocolColor}
|
||||
t={t}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed control row - positioned independently. Episode-specific
|
||||
buttons are conditional inside; Stop is always available. */}
|
||||
<CastPlayerEpisodeControls
|
||||
insetBottom={insets.bottom}
|
||||
currentItemId={currentItem.Id}
|
||||
episodes={episodes}
|
||||
nextEpisode={nextEpisode}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
onPressEpisodes={() => setShowEpisodeList(true)}
|
||||
hasChapters={hasChapters}
|
||||
onPressChapters={() => setChapterListVisible(true)}
|
||||
loadEpisode={loadEpisode}
|
||||
router={router}
|
||||
/>
|
||||
|
||||
{/* Fixed bottom controls area */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 10,
|
||||
left: 20,
|
||||
right: 20,
|
||||
zIndex: 98,
|
||||
}}
|
||||
>
|
||||
{/* Progress slider with trickplay preview + time display */}
|
||||
<CastPlayerProgressBar
|
||||
sliderProgress={sliderProgress}
|
||||
sliderMin={sliderMin}
|
||||
sliderMax={sliderMax}
|
||||
isScrubbing={isScrubbing}
|
||||
trickplayTime={trickplayTime}
|
||||
setTrickplayTime={setTrickplayTime}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
calculateTrickplayUrl={calculateTrickplayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
protocolColor={protocolColor}
|
||||
chapters={currentItem?.Chapters}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Playback controls */}
|
||||
<CastPlayerTransportControls
|
||||
isPlaying={isPlaying}
|
||||
togglePlayPause={togglePlayPause}
|
||||
skipBackward={skipBackward}
|
||||
skipForward={skipForward}
|
||||
rewindSkipTime={settings?.rewindSkipTime}
|
||||
forwardSkipTime={settings?.forwardSkipTime}
|
||||
protocolColor={protocolColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Autoplay countdown overlay — bottom-centred above the episode
|
||||
control row and main controls. 320 wide card; centred via
|
||||
left/right:0 + alignItems:"center". */}
|
||||
{castAutoplay && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 280,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
zIndex: 99,
|
||||
}}
|
||||
pointerEvents='box-none'
|
||||
>
|
||||
<AutoplayCountdown
|
||||
nextEpisode={castAutoplay.nextEpisode}
|
||||
posterUrl={autoplayPosterUrl}
|
||||
secondsRemaining={castAutoplay.secondsRemaining}
|
||||
onPlayNow={onAutoplayPlayNow}
|
||||
onCancel={() => setCastAutoplay(null)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ChromecastDeviceSheet
|
||||
visible={showDeviceSheet}
|
||||
onClose={() => setShowDeviceSheet(false)}
|
||||
device={
|
||||
currentDevice && castDevice
|
||||
? { friendlyName: currentDevice }
|
||||
: null
|
||||
}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
// End the casting session and disconnect completely
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
setShowDeviceSheet(false);
|
||||
// Close player immediately after disconnecting
|
||||
setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Error disconnecting from Chromecast:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}}
|
||||
volume={volume}
|
||||
onVolumeChange={async (vol) => {
|
||||
try {
|
||||
setVolume(vol);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to set volume:", error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChromecastEpisodeList
|
||||
visible={showEpisodeList}
|
||||
onClose={() => setShowEpisodeList(false)}
|
||||
currentItem={currentItem}
|
||||
episodes={episodes}
|
||||
api={api}
|
||||
onSelectEpisode={async (episode) => {
|
||||
setShowEpisodeList(false);
|
||||
await loadEpisode(episode);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChapterList
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={progress * 1000}
|
||||
onSeek={(ms) => {
|
||||
remoteMediaClient?.seek({ position: ms / 1000 });
|
||||
}}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
|
||||
<ChromecastSettingsMenu
|
||||
visible={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
versions={availableVersions}
|
||||
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
|
||||
onVersionChange={(id) => {
|
||||
if (!fetchedItem) return;
|
||||
applySelection({
|
||||
...resolveSelection(fetchedItem, { mediaSourceId: id }),
|
||||
maxBitrate: currentSelection?.maxBitrate,
|
||||
});
|
||||
}}
|
||||
qualities={availableQualities}
|
||||
selectedMaxBitrate={currentSelection?.maxBitrate}
|
||||
onQualityChange={(value) => applySelection({ maxBitrate: value })}
|
||||
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
|
||||
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
|
||||
onAudioChange={(index) =>
|
||||
applySelection({ audioStreamIndex: index })
|
||||
}
|
||||
subtitleTracks={
|
||||
isEpisodeTransitioning ? [] : availableSubtitleTracks
|
||||
}
|
||||
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
|
||||
onSubtitleChange={(index) =>
|
||||
applySelection({ subtitleStreamIndex: index })
|
||||
}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
onPlaybackSpeedChange={(speed) => {
|
||||
setCurrentPlaybackSpeed(speed);
|
||||
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,11 @@ import {
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import {
|
||||
@@ -49,9 +51,10 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
@@ -59,10 +62,6 @@ import {
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
|
||||
@@ -80,6 +79,11 @@ export default function DirectPlayerPage() {
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
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">(
|
||||
"default",
|
||||
);
|
||||
@@ -131,6 +135,7 @@ export default function DirectPlayerPage() {
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
syncPlay: syncPlayStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -140,9 +145,23 @@ export default function DirectPlayerPage() {
|
||||
offline: string;
|
||||
/** Playback position in ticks. */
|
||||
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();
|
||||
|
||||
// SyncPlay integration
|
||||
const syncPlay = useSyncPlay();
|
||||
const {
|
||||
isEnabled: isSyncPlayEnabled,
|
||||
controller: syncPlayController,
|
||||
setPlayerControls,
|
||||
notifyBuffering,
|
||||
} = syncPlay;
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||
@@ -277,6 +296,11 @@ export default function DirectPlayerPage() {
|
||||
};
|
||||
|
||||
if (itemId) {
|
||||
setItem(null);
|
||||
setDownloadedItem(null);
|
||||
// Clear the previous episode's stream so the loader gate stays closed
|
||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||
setStream(null);
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
@@ -319,6 +343,12 @@ export default function DirectPlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure item matches the current itemId to avoid race conditions
|
||||
if (item.Id !== itemId) {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
@@ -391,6 +421,7 @@ export default function DirectPlayerPage() {
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
offline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -406,25 +437,103 @@ export default function DirectPlayerPage() {
|
||||
reportPlaybackStart();
|
||||
}, [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 () => {
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream.sessionId,
|
||||
await getPlaystateApi(api).reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
PlaySessionId: stream.sessionId,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
progress,
|
||||
offline,
|
||||
revalidateProgressCache,
|
||||
]);
|
||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
@@ -442,9 +551,10 @@ export default function DirectPlayerPage() {
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
return () => {
|
||||
reportPlaybackStopped();
|
||||
beforeRemoveListener();
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
}, [navigation, stop, reportPlaybackStopped]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(():
|
||||
| PlaybackProgressInfo
|
||||
@@ -479,35 +589,6 @@ export default function DirectPlayerPage() {
|
||||
isMuted,
|
||||
]);
|
||||
|
||||
// Declared after currentPlayStateInfo so the dependency array can reference
|
||||
// it without hitting the temporal dead zone.
|
||||
const togglePlay = useCallback(async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
playbackManager.reportPlaybackProgress(progressInfo);
|
||||
}
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
lightHapticFeedback,
|
||||
isPlaying,
|
||||
currentPlayStateInfo,
|
||||
playbackManager,
|
||||
offline,
|
||||
api,
|
||||
]);
|
||||
|
||||
const lastUrlUpdateTime = useSharedValue(0);
|
||||
const wasJustSeeking = useSharedValue(false);
|
||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||
@@ -650,10 +731,12 @@ export default function DirectPlayerPage() {
|
||||
const startPos = ticksToSeconds(startTicks);
|
||||
|
||||
// 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 = {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
autoplay: shouldAutoplay,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// 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);
|
||||
|
||||
// Add useEffect to handle mounting
|
||||
@@ -872,10 +990,21 @@ export default function DirectPlayerPage() {
|
||||
videoRef.current?.pause?.();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}, []);
|
||||
const seek = useCallback(
|
||||
(position: number) => {
|
||||
// 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
|
||||
const handleAudioIndexChange = useCallback(
|
||||
@@ -936,47 +1065,6 @@ export default function DirectPlayerPage() {
|
||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||
}, []);
|
||||
|
||||
// App-wide remote control: wrap the player's existing handlers so remote
|
||||
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
|
||||
const playbackController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
// togglePlay flips play/pause and reports progress to the server.
|
||||
playPause: () => {
|
||||
void togglePlay();
|
||||
},
|
||||
pause: () => {
|
||||
pause();
|
||||
},
|
||||
unpause: () => {
|
||||
play();
|
||||
},
|
||||
stop: () => {
|
||||
stop();
|
||||
},
|
||||
// PlaybackController seeks in ms; the player's seek already expects ms.
|
||||
seek: (positionMs: number) => {
|
||||
seek(positionMs);
|
||||
},
|
||||
// The player screen has no episode-loading path of its own — episode
|
||||
// navigation is handled inside <Controls> via router replacement — so
|
||||
// next/previous are honest no-ops here.
|
||||
next: () => {},
|
||||
previous: () => {},
|
||||
// Volume is device-level (react-native-volume-manager); the slider sends
|
||||
// 0-1 while setVolumeCb expects 0-100.
|
||||
setVolume: (level: number) => {
|
||||
void setVolumeCb(level * 100);
|
||||
},
|
||||
toggleMute: () => {
|
||||
void toggleMuteCb();
|
||||
},
|
||||
}),
|
||||
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
|
||||
);
|
||||
|
||||
// Active for the whole lifetime of the player screen; cleared on unmount.
|
||||
useRegisterPlaybackController(playbackController, true);
|
||||
|
||||
// Determine play method based on stream URL and media source
|
||||
const playMethod = useMemo<
|
||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||
@@ -1056,44 +1144,6 @@ export default function DirectPlayerPage() {
|
||||
}
|
||||
}, [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)
|
||||
const addSubtitleFile = useCallback(async (path: string) => {
|
||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||
@@ -1123,45 +1173,25 @@ export default function DirectPlayerPage() {
|
||||
return [];
|
||||
}, [isMounted]);
|
||||
|
||||
// TV: Navigate to next item
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings || isPlaybackStopped) return;
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(nextItem, settings, {
|
||||
indexes: {
|
||||
// Use the live selection (updated when the user changes tracks
|
||||
// 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);
|
||||
}, [
|
||||
/*
|
||||
* Item-level navigation (next / previous). Wraps SyncPlay dispatch,
|
||||
* platform-appropriate local navigation (replace on TV), and offline
|
||||
* param injection in a single hook so the in-player buttons and any
|
||||
* future entry points (autoplay overlay, episode picker, etc.) share
|
||||
* one implementation.
|
||||
*/
|
||||
const {
|
||||
goToNextItem: dispatchNextItem,
|
||||
goToPreviousItem: dispatchPreviousItem,
|
||||
} = usePlayerItemNavigation({
|
||||
nextItem,
|
||||
settings,
|
||||
currentSubtitleIndex,
|
||||
previousItem,
|
||||
mediaSource: stream?.mediaSource,
|
||||
currentAudioIndex,
|
||||
stream?.mediaSource,
|
||||
currentSubtitleIndex,
|
||||
bitrateValue,
|
||||
router,
|
||||
isPlaybackStopped,
|
||||
]);
|
||||
isDisabled: isPlaybackStopped,
|
||||
});
|
||||
|
||||
// Apply subtitle settings when video loads
|
||||
useEffect(() => {
|
||||
@@ -1308,12 +1338,16 @@ export default function DirectPlayerPage() {
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onPictureInPictureChange={_onPictureInPictureChange}
|
||||
syncPlayDelegated={isSyncPlayEnabled}
|
||||
onPipPlayRequest={_onPipPlayRequest}
|
||||
onPipPauseRequest={_onPipPauseRequest}
|
||||
onPipSkipRequest={_onPipSkipRequest}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
@@ -1362,8 +1396,8 @@ export default function DirectPlayerPage() {
|
||||
onSubtitleIndexChange={handleSubtitleIndexChange}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
goToPreviousItem={goToPreviousItem}
|
||||
goToNextItem={goToNextItem}
|
||||
goToPreviousItem={dispatchPreviousItem}
|
||||
goToNextItem={dispatchNextItem}
|
||||
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||
addSubtitleFile={addSubtitleFile}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
|
||||
254
app/_layout.tsx
254
app/_layout.tsx
@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
@@ -409,133 +410,136 @@ function Layout() {
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<SyncPlayProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
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
|
||||
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
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</SyncPlayProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
|
||||
122
components/BitRateSheet.tsx
Normal file
122
components/BitRateSheet.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected?: Bitrate | null;
|
||||
inverted?: boolean | null;
|
||||
}
|
||||
|
||||
export const BitrateSheet: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}, [inverted]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.quality")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("item_card.quality")}
|
||||
data={sorted}
|
||||
values={selected ? [selected] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).key || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as Bitrate | undefined;
|
||||
if (chosen) onChange(chosen);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -10,31 +10,36 @@ export type Bitrate = {
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{ key: "Max", value: undefined },
|
||||
{ key: "200 Mb/s", value: 200000000 },
|
||||
{ key: "180 Mb/s", value: 180000000 },
|
||||
{ key: "140 Mb/s", value: 140000000 },
|
||||
{ key: "120 Mb/s", value: 120000000 },
|
||||
{ key: "110 Mb/s", value: 110000000 },
|
||||
{ key: "100 Mb/s", value: 100000000 },
|
||||
{ key: "90 Mb/s", value: 90000000 },
|
||||
{ key: "80 Mb/s", value: 80000000 },
|
||||
{ key: "70 Mb/s", value: 70000000 },
|
||||
{ key: "60 Mb/s", value: 60000000 },
|
||||
{ key: "50 Mb/s", value: 50000000 },
|
||||
{ key: "40 Mb/s", value: 40000000 },
|
||||
{ key: "30 Mb/s", value: 30000000 },
|
||||
{ key: "20 Mb/s", value: 20000000 },
|
||||
{ key: "15 Mb/s", value: 15000000 },
|
||||
{ key: "10 Mb/s", value: 10000000 },
|
||||
{ key: "8 Mb/s", value: 8000000 },
|
||||
{ key: "5 Mb/s", value: 5000000 },
|
||||
{ key: "4 Mb/s", value: 4000000 },
|
||||
{ key: "3 Mb/s", value: 3000000 },
|
||||
{ key: "2 Mb/s", value: 2000000 },
|
||||
{ key: "1 Mb/s", value: 1000000 },
|
||||
{ key: "720 Kb/s", value: 720000 },
|
||||
{ key: "420 Kb/s", value: 420000 },
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
export function Chromecast({
|
||||
@@ -26,136 +18,23 @@ export function Chromecast({
|
||||
background = "transparent",
|
||||
...props
|
||||
}) {
|
||||
// Hooks called for their side effects (keep Chromecast session active)
|
||||
useRemoteMediaClient();
|
||||
useCastDevice();
|
||||
const castState = useCastState();
|
||||
useDevices();
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Connection menu state
|
||||
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
||||
const isConnected = castState === CastState.CONNECTED;
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
||||
const playSessionIdRef = useRef<string | null>(null);
|
||||
const lastContentIdRef = useRef<string | null>(null);
|
||||
const discoveryAttempts = useRef(0);
|
||||
const maxDiscoveryAttempts = 3;
|
||||
|
||||
// Enhanced discovery with retry mechanism - runs once on mount
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
let retryTimeout: NodeJS.Timeout;
|
||||
|
||||
const startDiscoveryWithRetry = async () => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
console.warn("DiscoveryManager is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any existing discovery first
|
||||
try {
|
||||
await discoveryManager.stopDiscovery();
|
||||
} catch {
|
||||
// Ignore errors when stopping
|
||||
}
|
||||
|
||||
// Start fresh discovery
|
||||
await discoveryManager.startDiscovery();
|
||||
discoveryAttempts.current = 0; // Reset on success
|
||||
} catch (error) {
|
||||
console.error("[Chromecast Discovery] Failed:", error);
|
||||
|
||||
// Retry on error
|
||||
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
|
||||
discoveryAttempts.current++;
|
||||
retryTimeout = setTimeout(() => {
|
||||
if (isSubscribed) {
|
||||
startDiscoveryWithRetry();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
startDiscoveryWithRetry();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
};
|
||||
}, [discoveryManager]); // Only re-run if discoveryManager changes
|
||||
|
||||
// Report video progress to Jellyfin server
|
||||
useEffect(() => {
|
||||
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamPosition = mediaStatus.streamPosition || 0;
|
||||
const playerState = mediaStatus.playerState || null;
|
||||
|
||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
||||
const positionChanged =
|
||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
||||
if (!positionChanged && !stateChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentId = mediaStatus.mediaInfo.contentId;
|
||||
|
||||
// Generate a new PlaySessionId when the content changes
|
||||
if (contentId !== lastContentIdRef.current) {
|
||||
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
|
||||
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
},
|
||||
);
|
||||
lastContentIdRef.current = contentId;
|
||||
}
|
||||
|
||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||
const isPaused = mediaStatus.playerState === "paused";
|
||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
||||
|
||||
const progressInfo: PlaybackProgressInfo = {
|
||||
ItemId: contentId,
|
||||
PositionTicks: positionTicks,
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: playSessionIdRef.current || contentId,
|
||||
};
|
||||
|
||||
getPlaystateApi(api)
|
||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||
.then(() => {
|
||||
lastReportedProgressRef.current = streamPosition;
|
||||
lastReportedPlayerStateRef.current = playerState;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to report Chromecast progress:", error);
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.mediaInfo?.contentUrl,
|
||||
]);
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
@@ -164,92 +43,50 @@ export function Chromecast({
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
// Handle press - show connection menu when connected, otherwise show cast dialog
|
||||
const handlePress = useCallback(() => {
|
||||
if (isConnected) {
|
||||
if (mediaStatus?.currentItemId) {
|
||||
// Media is playing - navigate to full player
|
||||
router.push("/casting-player");
|
||||
} else {
|
||||
// Connected but no media - show connection menu
|
||||
setShowConnectionMenu(true);
|
||||
}
|
||||
} else {
|
||||
// Not connected - show cast dialog
|
||||
CastContext.showCastDialog();
|
||||
}
|
||||
}, [isConnected, mediaStatus?.currentItemId]);
|
||||
|
||||
// Handle disconnect from Chromecast
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
} catch (error) {
|
||||
console.error("[Chromecast] Disconnect error:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<>
|
||||
<Pressable className='mr-4' onPress={handlePress} {...props}>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</Pressable>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
<Pressable
|
||||
className='mr-4'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<>
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={handlePress}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</RoundButton>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoundButton size='large' onPress={handlePress} {...props}>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</RoundButton>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
<RoundButton
|
||||
size='large'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
@@ -63,7 +57,6 @@ interface PlatformDropdownProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
disabled?: boolean;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
@@ -214,31 +207,10 @@ const PlatformDropdownComponent = ({
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
|
||||
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
|
||||
disabled: isDisabled,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||
const [triggerSize, setTriggerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setTriggerSize((prev) =>
|
||||
prev && prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height },
|
||||
);
|
||||
};
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
@@ -269,32 +241,11 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||
// height depends on the parent while the parent depends on the Host — a
|
||||
// circular dependency that collapses to 0 for any selector nested more than
|
||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||
// fills a concrete box.
|
||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||
return (
|
||||
<View style={triggerSize ?? { opacity: 0 }}>
|
||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||
sizes to the trigger's content rather than to its parent. */}
|
||||
<View
|
||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||
pointerEvents='none'
|
||||
aria-hidden
|
||||
onLayout={handleMeasureTrigger}
|
||||
>
|
||||
<View>
|
||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||
@@ -428,14 +379,8 @@ const PlatformDropdownComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaPlayerState,
|
||||
MediaStreamType,
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
@@ -24,17 +23,20 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { chromecast } from "../utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -56,7 +58,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
@@ -65,55 +66,48 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
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
|
||||
const effectiveColors = colors || globalColorAtom;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(effectiveColors);
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { settings } = useSettings();
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string) => {
|
||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
}
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
(opts: Parameters<typeof playItem>[1]) => {
|
||||
void playItem(item, opts);
|
||||
},
|
||||
[router, isOffline],
|
||||
[item, playItem],
|
||||
);
|
||||
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
// Default play options derived from the page's track / source / bitrate
|
||||
// pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online
|
||||
// routing; we just need to pick a destination (device vs Chromecast).
|
||||
const defaultOpts = {
|
||||
audioIndex: selectedOptions.audioIndex,
|
||||
subtitleIndex: selectedOptions.subtitleIndex,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||
bitrateValue: selectedOptions.bitrate?.value,
|
||||
};
|
||||
|
||||
if (!client) {
|
||||
goToPlayer(queryString);
|
||||
goToPlayer(defaultOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = [
|
||||
t("casting_player.chromecast"),
|
||||
t("casting_player.device"),
|
||||
t("casting_player.cancel"),
|
||||
];
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -122,14 +116,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
// Compare item IDs AND check if media is actually playing (not stopped/idle)
|
||||
const currentContentId = mediaStatus?.mediaInfo?.contentId;
|
||||
const isMediaActive =
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
|
||||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
isMediaActive && currentContentId && currentContentId === item?.Id;
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
@@ -137,8 +126,30 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (state && state !== PlayServicesState.SUCCESS) {
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
} else {
|
||||
if (!api || !user?.Id || !item?.Id) {
|
||||
console.warn("Missing parameters for Chromecast streaming");
|
||||
// Check if user wants H265 for Chromecast
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
|
||||
// Validate required parameters before calling getStreamUrl
|
||||
if (!api) {
|
||||
console.warn("API not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!user?.Id) {
|
||||
console.warn(
|
||||
"User not authenticated for Chromecast streaming",
|
||||
);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!item?.Id) {
|
||||
console.warn("Item not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
@@ -146,43 +157,116 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const startPositionMs =
|
||||
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client,
|
||||
device: castDevice,
|
||||
api,
|
||||
item,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: {
|
||||
// Get a new URL with the Chromecast device profile
|
||||
try {
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
maxBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||
startPositionMs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error("[PlayButton] cast load failed:", result.error);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("URL: ", data?.url, enableH265);
|
||||
|
||||
if (!isOpeningCurrentlyPlayingMedia) {
|
||||
router.push("/casting-player");
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate start time in seconds from playback position
|
||||
const startTimeSeconds =
|
||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||
|
||||
// Calculate stream duration in seconds from runtime
|
||||
const streamDurationSeconds = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
goToPlayer(queryString);
|
||||
goToPlayer(defaultOpts);
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
@@ -192,36 +276,24 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}, [
|
||||
item,
|
||||
client,
|
||||
castDevice,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
router,
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
selectedOptions,
|
||||
goToPlayer,
|
||||
isOffline,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
goToPlayer({ forceOffline: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,13 +316,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideModal();
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
goToPlayer({ forceOffline: true });
|
||||
}}
|
||||
color='purple'
|
||||
>
|
||||
@@ -287,13 +353,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
{
|
||||
text: t("player.downloaded_file_yes"),
|
||||
onPress: () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
goToPlayer({ forceOffline: true });
|
||||
},
|
||||
isPreferred: true,
|
||||
},
|
||||
@@ -317,13 +377,12 @@ export const PlayButton: React.FC<Props> = ({
|
||||
handleNormalPlayFlow();
|
||||
}, [
|
||||
item,
|
||||
lightHapticFeedback,
|
||||
isOffline,
|
||||
handleNormalPlayFlow,
|
||||
goToPlayer,
|
||||
t,
|
||||
showModal,
|
||||
hideModal,
|
||||
effectiveColors,
|
||||
]);
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
|
||||
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
|
||||
* fires regardless of which screen is open.
|
||||
*/
|
||||
|
||||
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
|
||||
|
||||
export function CastAutoplayWatcher() {
|
||||
useCastAutoplay();
|
||||
return null;
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Mini Player
|
||||
* Works with all supported casting protocols
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, {
|
||||
SlideInDown,
|
||||
SlideOutDown,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export const CastingMiniPlayer: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
|
||||
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
|
||||
|
||||
// Trickplay support - pass currentItem as BaseItemDto or null
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
currentItem || null,
|
||||
);
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Slider shared values
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
|
||||
// Live progress state that updates every second when playing
|
||||
const [liveProgress, setLiveProgress] = useState(
|
||||
mediaStatus?.streamPosition || 0,
|
||||
);
|
||||
|
||||
// Track baseline for elapsed-time computation
|
||||
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
|
||||
const baselineTimestampRef = useRef(Date.now());
|
||||
|
||||
// Sync live progress with mediaStatus and poll every second when playing
|
||||
useEffect(() => {
|
||||
// Resync baseline whenever mediaStatus reports a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||
baselineTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update based on elapsed real time when playing
|
||||
const interval = setInterval(() => {
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
|
||||
const elapsed =
|
||||
((Date.now() - baselineTimestampRef.current) *
|
||||
(mediaStatus.playbackRate || 1)) /
|
||||
1000;
|
||||
setLiveProgress(baselinePositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||
baselineTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.playbackRate,
|
||||
]);
|
||||
|
||||
const progress = liveProgress * 1000; // Convert to ms
|
||||
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
|
||||
// Update slider max value when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration;
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Sync slider progress with live progress (when not scrubbing)
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress >= 0) {
|
||||
sliderProgress.value = progress;
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
// For episodes, use series poster; for other content, use item poster
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api?.basePath || !currentItem) return null;
|
||||
|
||||
if (
|
||||
currentItem.Type === "Episode" &&
|
||||
currentItem.SeriesId &&
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.SeasonId
|
||||
) {
|
||||
// Build series poster URL using SeriesId and series-level image tag
|
||||
const imageTag = currentItem.SeriesPrimaryImageTag || "";
|
||||
const tagParam = imageTag ? `&tag=${imageTag}` : "";
|
||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
|
||||
}
|
||||
|
||||
// For non-episodes, use item's own poster
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
80,
|
||||
120,
|
||||
);
|
||||
}, [api?.basePath, currentItem]);
|
||||
|
||||
// Hide mini player when:
|
||||
// - No cast device connected
|
||||
// - No media info (currentItem)
|
||||
// - No media status
|
||||
// - Media is stopped (IDLE state)
|
||||
// - Media is unknown state
|
||||
const playerState = mediaStatus?.playerState;
|
||||
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
||||
|
||||
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
||||
|
||||
const handlePress = () => {
|
||||
router.push("/casting-player");
|
||||
};
|
||||
|
||||
const handleTogglePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Pause error:", error);
|
||||
});
|
||||
} else {
|
||||
remoteMediaClient?.play()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Play error:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{/* Interactive progress slider with trickplay */}
|
||||
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 20 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isScrubbing.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Calculate trickplay preview
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
// Update time display for trickplay bubble
|
||||
const progressInSeconds = Math.floor(
|
||||
ticksToSeconds(progressInTicks),
|
||||
);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTrickplayTime({ hours, minutes, seconds });
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isScrubbing.current = false;
|
||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||
const positionSeconds = value / 1000;
|
||||
if (remoteMediaClient && duration > 0) {
|
||||
remoteMediaClient
|
||||
.seek({ position: positionSeconds })
|
||||
.catch((error) => {
|
||||
console.error("[Mini Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={190}
|
||||
/>
|
||||
)}
|
||||
bubbleMaxWidth={190}
|
||||
bubbleWidth={190}
|
||||
bubbleTranslateY={-20}
|
||||
sliderHeight={3}
|
||||
thumbWidth={14}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={handlePress}>
|
||||
{/* Content */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
paddingTop: 6,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 60,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.Name}
|
||||
</Text>
|
||||
{currentItem.SeriesName && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.SeriesName}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={12} color={protocolColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 11,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{castDevice.friendlyName || "Chromecast"}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatTime(progress)} / {formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stop button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Stop error:", error);
|
||||
});
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons name='stop' size={24} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTogglePlayPause();
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Casting Player Episode Controls
|
||||
* Fixed control row: episode list, previous, next, stop.
|
||||
* Episode-specific buttons (list / previous / next) are conditional;
|
||||
* Stop is always rendered so movies still get a Stop button.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { ImperativeRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerEpisodeControlsProps {
|
||||
/** Bottom safe-area inset, used to offset the fixed control row. */
|
||||
insetBottom: number;
|
||||
/** Id of the currently playing episode. */
|
||||
currentItemId: BaseItemDto["Id"];
|
||||
/** Full episode list for the series. */
|
||||
episodes: BaseItemDto[];
|
||||
/** Next episode in the list, or null if none. */
|
||||
nextEpisode: BaseItemDto | null;
|
||||
/** Remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Open the episode list modal. */
|
||||
onPressEpisodes: () => void;
|
||||
/** Whether the current item exposes chapter markers. */
|
||||
hasChapters: boolean;
|
||||
/** Open the chapter list modal. */
|
||||
onPressChapters: () => void;
|
||||
/** Load a different episode on the Chromecast. */
|
||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||
/** Expo Router instance for navigation on stop. */
|
||||
router: ImperativeRouter;
|
||||
}
|
||||
|
||||
export function CastPlayerEpisodeControls({
|
||||
insetBottom,
|
||||
currentItemId,
|
||||
episodes,
|
||||
nextEpisode,
|
||||
remoteMediaClient,
|
||||
onPressEpisodes,
|
||||
hasChapters,
|
||||
onPressChapters,
|
||||
loadEpisode,
|
||||
router,
|
||||
}: CastPlayerEpisodeControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasEpisodeList = episodes.length > 0;
|
||||
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
|
||||
const hasNext = nextEpisode != null;
|
||||
|
||||
// Count of buttons actually rendered (Stop is always rendered).
|
||||
const buttonCount =
|
||||
1 +
|
||||
(hasEpisodeList ? 1 : 0) +
|
||||
(hasChapters ? 1 : 0) +
|
||||
(hasPrevious ? 1 : 0) +
|
||||
(hasNext ? 1 : 0);
|
||||
|
||||
// When Stop is the only button (movies), render it full-width with a label.
|
||||
const isLoneStop = buttonCount === 1;
|
||||
|
||||
// Each button stretches evenly only when the row holds more than one;
|
||||
// a lone Stop button keeps its intrinsic size and stays centered.
|
||||
const buttonStyle = {
|
||||
...(buttonCount > 1 ? { flex: 1 } : {}),
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
flexDirection: "row" as const,
|
||||
justifyContent: "center" as const,
|
||||
alignItems: "center" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insetBottom + 200,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
|
||||
{hasEpisodeList && (
|
||||
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
|
||||
<Ionicons name='list' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
|
||||
{hasChapters && (
|
||||
<Pressable onPress={onPressChapters} style={buttonStyle}>
|
||||
<Ionicons name='bookmarks' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Previous episode button - only rendered when a previous episode exists */}
|
||||
{hasPrevious && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const currentIndex = episodes.findIndex(
|
||||
(ep) => ep.Id === currentItemId,
|
||||
);
|
||||
if (currentIndex > 0) {
|
||||
await loadEpisode(episodes[currentIndex - 1]);
|
||||
}
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Ionicons name='play-skip-back' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Next episode button - only rendered when a next episode exists */}
|
||||
{hasNext && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (nextEpisode) {
|
||||
await loadEpisode(nextEpisode);
|
||||
}
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
// Stop the current media playback (don't disconnect from Chromecast)
|
||||
if (remoteMediaClient) {
|
||||
await remoteMediaClient.stop();
|
||||
}
|
||||
|
||||
// Navigate back/close the player (mini player will disappear since no media is playing)
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Error stopping playback:", error);
|
||||
// Navigate anyway
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
|
||||
>
|
||||
<Ionicons name='stop-circle' size={22} color='white' />
|
||||
{isLoneStop && (
|
||||
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
|
||||
{t("casting_player.stop")}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Casting Player Header
|
||||
* Fixed top bar: dismiss button, connection indicator, settings button.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerHeaderProps {
|
||||
/** Top safe-area inset, used to offset the fixed header. */
|
||||
insetTop: number;
|
||||
/** Streamyfin protocol accent color. */
|
||||
protocolColor: string;
|
||||
/** Friendly name of the connected cast device, or null. */
|
||||
currentDevice: string | null;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
/** Dismiss the casting player modal. */
|
||||
onDismiss: () => void;
|
||||
/** Open the device sheet (connection indicator press). */
|
||||
onPressConnectionIndicator: () => void;
|
||||
/** Open the settings menu. */
|
||||
onPressSettings: () => void;
|
||||
}
|
||||
|
||||
export function CastPlayerHeader({
|
||||
insetTop,
|
||||
protocolColor,
|
||||
currentDevice,
|
||||
t,
|
||||
onDismiss,
|
||||
onPressConnectionIndicator,
|
||||
onPressSettings,
|
||||
}: CastPlayerHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insetTop + 8,
|
||||
left: 20,
|
||||
right: 20,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
|
||||
<Ionicons name='chevron-down' size={32} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Connection indicator */}
|
||||
<Pressable
|
||||
onPress={onPressConnectionIndicator}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: protocolColor,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{currentDevice || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={onPressSettings}
|
||||
style={{ padding: 8, marginRight: -8 }}
|
||||
>
|
||||
<Ionicons name='settings-outline' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Casting Player Poster
|
||||
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import {
|
||||
MediaPlayerState,
|
||||
type MediaStatus,
|
||||
type RemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
|
||||
|
||||
interface CastPlayerPosterProps {
|
||||
/** Poster image URL, or null when unavailable. */
|
||||
posterUrl: string | null;
|
||||
/** Whether the cast media is currently buffering. */
|
||||
isBuffering: boolean;
|
||||
/** The current playback segment (intro/credits/etc.), or null. */
|
||||
currentSegment: ChromecastSegments["currentSegment"];
|
||||
/** Skip the intro segment. */
|
||||
skipIntro: ChromecastSegments["skipIntro"];
|
||||
/** Skip the credits segment. */
|
||||
skipCredits: ChromecastSegments["skipCredits"];
|
||||
/** Skip the current generic segment. */
|
||||
skipSegment: ChromecastSegments["skipSegment"];
|
||||
/** The remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Raw Chromecast media status. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Theme accent color. */
|
||||
protocolColor: string;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerPoster({
|
||||
posterUrl,
|
||||
isBuffering,
|
||||
currentSegment,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
skipSegment,
|
||||
remoteMediaClient,
|
||||
mediaStatus,
|
||||
protocolColor,
|
||||
t,
|
||||
}: CastPlayerPosterProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 280,
|
||||
height: 420,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={64} color='#333' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip intro/credits bar at bottom of poster */}
|
||||
{currentSegment && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (!remoteMediaClient) return;
|
||||
try {
|
||||
const seekFn = async (positionMs: number) => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
||||
) {
|
||||
await remoteMediaClient.seek({
|
||||
position: positionMs / 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (currentSegment.type === "intro") {
|
||||
await skipIntro(seekFn);
|
||||
} else if (currentSegment.type === "credits") {
|
||||
await skipCredits(seekFn);
|
||||
} else {
|
||||
await skipSegment(seekFn);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Skip error:", error);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: protocolColor,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={18} color='white' />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Buffering overlay */}
|
||||
{isBuffering && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color={protocolColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{t("casting_player.buffering")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Casting Player Progress Bar
|
||||
* Progress slider with trickplay preview bubble and current/end time display.
|
||||
*/
|
||||
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface CastPlayerProgressBarProps {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: { current: boolean };
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}) => void;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
/** Current playback progress, in seconds. */
|
||||
progress: number;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
/** Remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Theme color used for the slider track and bubbles. */
|
||||
protocolColor: string;
|
||||
/** Chapter markers for the current item, or null/undefined if none. */
|
||||
chapters?: ChapterInfo[] | null;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerProgressBar({
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
progress,
|
||||
duration,
|
||||
remoteMediaClient,
|
||||
protocolColor,
|
||||
chapters,
|
||||
t,
|
||||
}: CastPlayerProgressBarProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Progress slider with trickplay preview */}
|
||||
<View style={{ marginTop: 8, height: 40 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isScrubbing.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Calculate trickplay preview
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
// Update time display for trickplay bubble
|
||||
const progressInSeconds = Math.floor(
|
||||
ticksToSeconds(progressInTicks),
|
||||
);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTrickplayTime({ hours, minutes, seconds });
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isScrubbing.current = false;
|
||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||
const positionSeconds = value / 1000;
|
||||
if (remoteMediaClient && duration > 0) {
|
||||
remoteMediaClient
|
||||
.seek({ position: positionSeconds })
|
||||
.catch((error) => {
|
||||
console.error("[Casting Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={220}
|
||||
/>
|
||||
)}
|
||||
bubbleMaxWidth={220}
|
||||
bubbleWidth={220}
|
||||
bubbleTranslateY={-20}
|
||||
sliderHeight={6}
|
||||
thumbWidth={16}
|
||||
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
|
||||
/>
|
||||
<ChapterTicks
|
||||
markers={chapterMarkers(chapters, duration * 1000)}
|
||||
height={4}
|
||||
color='#cccccc'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time display */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(progress * 1000)}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{t("casting_player.ending_at", {
|
||||
time: calculateEndingTime(progress * 1000, duration * 1000),
|
||||
})}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(duration * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Casting Player Title Area
|
||||
* Fixed title bar: item title and optional grey episode/season info.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/casting/helpers";
|
||||
|
||||
interface CastPlayerTitleProps {
|
||||
/** Top safe-area inset, used to offset the fixed title area. */
|
||||
insetTop: number;
|
||||
/** The currently playing item. */
|
||||
currentItem: BaseItemDto;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerTitle({
|
||||
insetTop,
|
||||
currentItem,
|
||||
t,
|
||||
}: CastPlayerTitleProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insetTop + 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 95,
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
|
||||
</Text>
|
||||
|
||||
{/* Grey episode/season info */}
|
||||
{currentItem.Type === "Episode" &&
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.IndexNumber !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{t("casting_player.season_episode_format", {
|
||||
season: currentItem.ParentIndexNumber,
|
||||
episode: currentItem.IndexNumber,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Casting Player Transport Controls
|
||||
* Playback transport row: rewind, play/pause, forward.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerTransportControlsProps {
|
||||
/** Whether playback is currently playing. */
|
||||
isPlaying: boolean;
|
||||
/** Toggle play/pause on the Chromecast. */
|
||||
togglePlayPause: () => Promise<void>;
|
||||
/** Skip backward by the given number of seconds. */
|
||||
skipBackward: (seconds: number) => Promise<void>;
|
||||
/** Skip forward by the given number of seconds. */
|
||||
skipForward: (seconds: number) => Promise<void>;
|
||||
/** Configured rewind skip time in seconds, shown on the rewind button. */
|
||||
rewindSkipTime: number | null | undefined;
|
||||
/** Configured forward skip time in seconds, shown on the forward button. */
|
||||
forwardSkipTime: number | null | undefined;
|
||||
/** Accent color used for the play/pause button background. */
|
||||
protocolColor: string;
|
||||
}
|
||||
|
||||
export function CastPlayerTransportControls({
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
skipBackward,
|
||||
skipForward,
|
||||
rewindSkipTime,
|
||||
forwardSkipTime,
|
||||
protocolColor,
|
||||
}: CastPlayerTransportControlsProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 32,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{/* Rewind (use settings) */}
|
||||
<Pressable
|
||||
onPress={() => skipBackward(rewindSkipTime ?? 10)}
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={48}
|
||||
color='white'
|
||||
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
||||
/>
|
||||
{rewindSkipTime != null && (
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{rewindSkipTime}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Pressable
|
||||
onPress={togglePlayPause}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={36}
|
||||
color='white'
|
||||
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Forward (use settings) */}
|
||||
<Pressable
|
||||
onPress={() => skipForward(forwardSkipTime ?? 10)}
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='refresh-outline' size={48} color='white' />
|
||||
{forwardSkipTime != null && (
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{forwardSkipTime}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Shared scrub-preview bubble for the casting progress bars.
|
||||
*
|
||||
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
|
||||
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
|
||||
* component therefore does NO horizontal positioning — it only anchors itself
|
||||
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
|
||||
*/
|
||||
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { formatTrickplayTime } from "@/utils/casting/helpers";
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface CastTrickplayBubbleProps {
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
/** Scrub time to display. */
|
||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||
/** Trickplay tile width in px (220 main player, 140 mini-player). */
|
||||
tileWidth: number;
|
||||
}
|
||||
|
||||
export function CastTrickplayBubble({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
trickplayTime,
|
||||
tileWidth,
|
||||
}: CastTrickplayBubbleProps) {
|
||||
const timeText = (
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
}}
|
||||
>
|
||||
{formatTrickplayTime(trickplayTime)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Anchored to the bottom of the slider-positioned container, growing upward,
|
||||
// and filling the container width (left/right: 0) so it stays centred on the
|
||||
// thumb. No horizontal maths here — the slider owns horizontal placement.
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{timeText}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{timeText}
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
||||
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeArea = useControlsSafeAreaInsets();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
@@ -79,7 +81,17 @@ function ChapterListComponent({
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
marginLeft: safeArea.left,
|
||||
marginRight: safeArea.right,
|
||||
paddingBottom: safeArea.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Chromecast Connection Menu
|
||||
* Shows device info, volume control, and disconnect option
|
||||
* Simple menu for when connected but not actively controlling playback
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastDevice, useCastSession } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastConnectionMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDisconnect?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastConnectionMenu: React.FC<
|
||||
ChromecastConnectionMenuProps
|
||||
> = ({ visible, onClose, onDisconnect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const castDevice = useCastDevice();
|
||||
const castSession = useCastSession();
|
||||
|
||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
||||
const [displayVolume, setDisplayVolume] = useState(50);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isMutedRef = useRef(false);
|
||||
const volumeValue = useSharedValue(50);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(50);
|
||||
|
||||
const protocolColor = "#a855f7";
|
||||
|
||||
// Get initial volume and mute state when menu opens
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial states
|
||||
const fetchInitialState = async () => {
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
fetchInitialState();
|
||||
|
||||
// Poll for external volume changes (physical buttons) - only when not sliding
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
// Only update if external change detected (not our own change)
|
||||
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
if (muted !== isMutedRef.current) {
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, 1000); // Poll less frequently
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
// Volume change during sliding - update display only, don't call API
|
||||
const handleVolumeChange = useCallback((value: number) => {
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
}, []);
|
||||
|
||||
// Volume change complete - call API
|
||||
const handleVolumeComplete = useCallback(
|
||||
async (value: number) => {
|
||||
isSliding.current = false;
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
lastSetVolume.current = rounded;
|
||||
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(rounded / 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Volume error:", error);
|
||||
}
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMute = !isMuted;
|
||||
await castSession.setMute(newMute);
|
||||
isMutedRef.current = newMute;
|
||||
setIsMuted(newMute);
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Mute error:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Disconnect
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
if (onDisconnect) {
|
||||
await onDisconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Disconnect error:", error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [onDisconnect, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with device name */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={20} color='white' />
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{castDevice?.friendlyName || t("casting_player.chromecast")}
|
||||
</Text>
|
||||
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
||||
{t("casting_player.connected")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Volume Control */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("casting_player.volume")}
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? protocolColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isSliding.current = true;
|
||||
}}
|
||||
onValueChange={async (value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
// Unmute when adjusting volume - use ref to avoid
|
||||
// stale closure and prevent repeated async calls
|
||||
if (isMutedRef.current) {
|
||||
isMutedRef.current = false;
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession?.setMute(false);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
"[ChromecastConnectionMenu] Failed to unmute:",
|
||||
error,
|
||||
);
|
||||
isMutedRef.current = true;
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSlidingComplete={handleVolumeComplete}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
style={{
|
||||
backgroundColor: protocolColor,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='power' size={20} color='white' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
||||
>
|
||||
{t("casting_player.disconnect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* Chromecast Device Info Sheet
|
||||
* Shows device details, volume control, and disconnect option
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastSession } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastDeviceSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
device: { friendlyName?: string } | null;
|
||||
onDisconnect: () => Promise<void>;
|
||||
volume?: number;
|
||||
onVolumeChange?: (volume: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
device,
|
||||
onDisconnect,
|
||||
volume = 0.5,
|
||||
onVolumeChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
||||
const volumeValue = useSharedValue(volume * 100);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const castSession = useCastSession();
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(Math.round(volume * 100));
|
||||
|
||||
// Sync volume slider with prop changes (updates from physical buttons)
|
||||
// Skip updates while user is actively sliding to avoid overwriting drag
|
||||
useEffect(() => {
|
||||
if (isSliding.current) return;
|
||||
volumeValue.value = volume * 100;
|
||||
setDisplayVolume(Math.round(volume * 100));
|
||||
}, [volume, volumeValue]);
|
||||
|
||||
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial mute state
|
||||
castSession
|
||||
.isMute()
|
||||
.then(setIsMuted)
|
||||
.catch(() => {});
|
||||
|
||||
// Poll CastSession for device volume and mute state (only when not sliding)
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const deviceVolume = await castSession.getVolume();
|
||||
if (deviceVolume !== undefined) {
|
||||
const volumePercent = Math.round(deviceVolume * 100);
|
||||
// Only update if external change (physical buttons)
|
||||
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(volumePercent);
|
||||
volumeValue.value = volumePercent;
|
||||
lastSetVolume.current = volumePercent;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mute state
|
||||
const muteState = await castSession.isMute();
|
||||
setIsMuted(muteState);
|
||||
} catch {
|
||||
// Ignore errors - device might be disconnected
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
await onDisconnect();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeComplete = async (value: number) => {
|
||||
const newVolume = value / 100;
|
||||
setDisplayVolume(Math.round(value));
|
||||
try {
|
||||
// Use CastSession.setVolume for DEVICE volume control
|
||||
// This works even when no media is playing, unlike setStreamVolume
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
} else if (onVolumeChange) {
|
||||
// Fallback to prop method if session not available
|
||||
await onVolumeChange(newVolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error setting volume:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced volume update during sliding for smooth live feedback
|
||||
const handleVolumeChange = useCallback(
|
||||
(value: number) => {
|
||||
setDisplayVolume(Math.round(value));
|
||||
|
||||
// Debounce the API call to avoid too many requests
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
const newVolume = value / 100;
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during sliding
|
||||
}
|
||||
}, 150); // 150ms debounce
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute state
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMuteState = !isMuted;
|
||||
await castSession.setMute(newMuteState);
|
||||
setIsMuted(newMuteState);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error toggling mute:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
||||
>
|
||||
{t("casting_player.chromecast")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
{t("casting_player.device_name")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
||||
>
|
||||
{device?.friendlyName || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Volume control */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("casting_player.volume")}
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Mute button */}
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
||||
bubbleBackgroundColor: "#a855f7",
|
||||
}}
|
||||
onSlidingStart={async () => {
|
||||
isSliding.current = true;
|
||||
// Auto-unmute when user starts adjusting volume
|
||||
if (isMuted && castSession) {
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession.setMute(false);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Failed to unmute:", error);
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isSliding.current = false;
|
||||
lastSetVolume.current = Math.round(value);
|
||||
handleVolumeComplete(value);
|
||||
}}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={{
|
||||
backgroundColor: "#a855f7",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: isDisconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='power'
|
||||
size={20}
|
||||
color='white'
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{isDisconnecting
|
||||
? t("casting_player.disconnecting")
|
||||
: t("casting_player.stop_casting")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* Episode List for Chromecast Player
|
||||
* Displays list of episodes for TV shows with thumbnails
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/casting/helpers";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface ChromecastEpisodeListProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentItem: BaseItemDto | null;
|
||||
episodes: BaseItemDto[];
|
||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
||||
api: Api | null;
|
||||
}
|
||||
|
||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentItem,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
api,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const scrollRetryCountRef = useRef(0);
|
||||
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const MAX_SCROLL_RETRIES = 3;
|
||||
|
||||
// Cleanup pending retry timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
scrollRetryTimeoutRef.current = null;
|
||||
}
|
||||
scrollRetryCountRef.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get unique seasons from episodes
|
||||
const seasons = useMemo(() => {
|
||||
const seasonSet = new Set<number>();
|
||||
for (const ep of episodes) {
|
||||
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
||||
seasonSet.add(ep.ParentIndexNumber);
|
||||
}
|
||||
}
|
||||
return Array.from(seasonSet).sort((a, b) => a - b);
|
||||
}, [episodes]);
|
||||
|
||||
// Filter episodes by selected season and exclude virtual episodes
|
||||
const filteredEpisodes = useMemo(() => {
|
||||
let eps = episodes;
|
||||
|
||||
// Filter by season if selected
|
||||
if (selectedSeason !== null) {
|
||||
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
||||
}
|
||||
|
||||
// Filter out virtual episodes (episodes without actual video files)
|
||||
// LocationType === "Virtual" means the episode doesn't have a media file
|
||||
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
||||
|
||||
return eps;
|
||||
}, [episodes, selectedSeason]);
|
||||
|
||||
// Set initial season to current episode's season
|
||||
useEffect(() => {
|
||||
if (currentItem?.ParentIndexNumber !== undefined) {
|
||||
setSelectedSeason(currentItem.ParentIndexNumber);
|
||||
}
|
||||
}, [currentItem]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset retry counter when visibility or data changes
|
||||
scrollRetryCountRef.current = 0;
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (visible && currentItem && filteredEpisodes.length > 0) {
|
||||
const currentIndex = filteredEpisodes.findIndex(
|
||||
(ep) => ep.Id === currentItem.Id,
|
||||
);
|
||||
if (currentIndex !== -1 && flatListRef.current) {
|
||||
// Delay to ensure FlatList is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: currentIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5, // Center the item
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [visible, currentItem, filteredEpisodes]);
|
||||
|
||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSelectEpisode(item);
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
padding: 12,
|
||||
// Translucent (not solid) purple so the dark base shows through and
|
||||
// the row's text — incl. the purple S:E label — stays readable. The
|
||||
// play-circle icon also marks the current episode.
|
||||
backgroundColor: isCurrentEpisode
|
||||
? "rgba(168, 85, 247, 0.25)"
|
||||
: "transparent",
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const imageUrl =
|
||||
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={32} color='#333' />
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Episode info */}
|
||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
|
||||
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
|
||||
</Text>
|
||||
{item.Overview && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
||||
{item.ParentIndexNumber !== undefined &&
|
||||
item.IndexNumber !== undefined && (
|
||||
<Text
|
||||
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
||||
>
|
||||
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
||||
{String(item.IndexNumber).padStart(2, "0")}
|
||||
</Text>
|
||||
)}
|
||||
{item.ProductionYear && (
|
||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
)}
|
||||
{item.RunTimeTicks && (
|
||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||
{Math.round(item.RunTimeTicks / 600000000)}{" "}
|
||||
{t("casting_player.minutes_short")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCurrentEpisode && (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-circle' size={24} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: insets.top,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: seasons.length > 1 ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.episodes")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Season selector */}
|
||||
{seasons.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8 }}
|
||||
>
|
||||
{seasons.map((season) => (
|
||||
<Pressable
|
||||
key={season}
|
||||
onPress={() => setSelectedSeason(season)}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor:
|
||||
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: selectedSeason === season ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{t("casting_player.season", { number: season })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Episode list */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={filteredEpisodes}
|
||||
renderItem={renderEpisode}
|
||||
keyExtractor={(item, index) => item.Id || `episode-${index}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// Bounded retry for scroll failures
|
||||
if (
|
||||
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
|
||||
info.index >= filteredEpisodes.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scrollRetryCountRef.current += 1;
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
scrollRetryTimeoutRef.current = setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: info.index,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,304 +0,0 @@
|
||||
/**
|
||||
* Chromecast Settings Menu
|
||||
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
|
||||
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
|
||||
|
||||
export interface VersionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface QualityOption {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
}
|
||||
|
||||
interface ChromecastSettingsMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
versions: VersionOption[];
|
||||
selectedVersionId: string;
|
||||
onVersionChange: (id: string) => void;
|
||||
qualities: QualityOption[];
|
||||
selectedMaxBitrate: number | undefined;
|
||||
onQualityChange: (value: number | undefined) => void;
|
||||
audioTracks: AudioTrack[];
|
||||
selectedAudioIndex: number;
|
||||
onAudioChange: (index: number) => void;
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
/** -1 = subtitles off. */
|
||||
selectedSubtitleIndex: number;
|
||||
onSubtitleChange: (index: number) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const ACCENT = "#a855f7";
|
||||
|
||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
versions,
|
||||
selectedVersionId,
|
||||
onVersionChange,
|
||||
qualities,
|
||||
selectedMaxBitrate,
|
||||
onQualityChange,
|
||||
audioTracks,
|
||||
selectedAudioIndex,
|
||||
onAudioChange,
|
||||
subtitleTracks,
|
||||
selectedSubtitleIndex,
|
||||
onSubtitleChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSection(expandedSection === section ? null : section);
|
||||
};
|
||||
|
||||
const renderSectionHeader = (
|
||||
title: string,
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
sectionKey: string,
|
||||
) => (
|
||||
<Pressable
|
||||
onPress={() => toggleSection(sectionKey)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<Ionicons name={icon} size={20} color='white' />
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color='#999'
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const renderRow = (
|
||||
key: string | number,
|
||||
label: string,
|
||||
sublabel: string | null,
|
||||
selected: boolean,
|
||||
onPress: () => void,
|
||||
) => (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => {
|
||||
onPress();
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor: selected ? "#2a2a2a" : "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
|
||||
{sublabel ? (
|
||||
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
|
||||
{sublabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "80%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.playback_settings")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
{/* Version — only when the item has more than one MediaSource */}
|
||||
{versions.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.version"),
|
||||
"albums-outline",
|
||||
"version",
|
||||
)}
|
||||
{versions.length > 1 && expandedSection === "version" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{versions.map((v) =>
|
||||
renderRow(
|
||||
v.id,
|
||||
v.name,
|
||||
null,
|
||||
v.id === selectedVersionId,
|
||||
() => onVersionChange(v.id),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Quality (bitrate cap) */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.quality"),
|
||||
"film-outline",
|
||||
"quality",
|
||||
)}
|
||||
{expandedSection === "quality" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{qualities.map((q) =>
|
||||
renderRow(
|
||||
q.key,
|
||||
q.key,
|
||||
null,
|
||||
q.value === selectedMaxBitrate,
|
||||
() => onQualityChange(q.value),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio — only when more than one track */}
|
||||
{audioTracks.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.audio"),
|
||||
"musical-notes",
|
||||
"audio",
|
||||
)}
|
||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{audioTracks.map((track) =>
|
||||
renderRow(
|
||||
track.index,
|
||||
track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown"),
|
||||
track.codec ? track.codec.toUpperCase() : null,
|
||||
track.index === selectedAudioIndex,
|
||||
() => onAudioChange(track.index),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitles */}
|
||||
{subtitleTracks.length > 0 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.subtitles"),
|
||||
"text",
|
||||
"subtitles",
|
||||
)}
|
||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{renderRow(
|
||||
"off",
|
||||
t("casting_player.none"),
|
||||
null,
|
||||
selectedSubtitleIndex < 0,
|
||||
() => onSubtitleChange(-1),
|
||||
)}
|
||||
{subtitleTracks.map((track) =>
|
||||
renderRow(
|
||||
track.index,
|
||||
track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown"),
|
||||
[
|
||||
track.codec ? track.codec.toUpperCase() : "",
|
||||
track.isForced ? t("casting_player.forced") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ") || null,
|
||||
track.index === selectedSubtitleIndex,
|
||||
() => onSubtitleChange(track.index),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Playback speed */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.playback_speed"),
|
||||
"speedometer",
|
||||
"speed",
|
||||
)}
|
||||
{expandedSection === "speed" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{PLAYBACK_SPEEDS.map((speed) =>
|
||||
renderRow(
|
||||
speed,
|
||||
speed === 1 ? t("casting_player.normal") : `${speed}x`,
|
||||
null,
|
||||
Math.abs(playbackSpeed - speed) < 0.01,
|
||||
() => onPlaybackSpeedChange(speed),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
||||
* Integrates with autoskip API for segment detection
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { isWithinSegment } from "@/utils/casting/helpers";
|
||||
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
|
||||
export const useChromecastSegments = (
|
||||
item: BaseItemDto | null,
|
||||
currentProgressMs: number,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Fetch segments from autoskip API
|
||||
const { data: segmentData } = useSegments(
|
||||
item?.Id || "",
|
||||
isOffline,
|
||||
undefined, // downloadedFiles parameter
|
||||
api,
|
||||
);
|
||||
|
||||
// Parse segments into usable format
|
||||
const segments = useMemo<ChromecastSegmentData>(() => {
|
||||
if (!segmentData) {
|
||||
return {
|
||||
intro: null,
|
||||
credits: null,
|
||||
recap: null,
|
||||
commercial: [],
|
||||
preview: [],
|
||||
};
|
||||
}
|
||||
|
||||
const intro =
|
||||
segmentData.introSegments && segmentData.introSegments.length > 0
|
||||
? {
|
||||
start: segmentData.introSegments[0].startTime,
|
||||
end: segmentData.introSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const credits =
|
||||
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
||||
? {
|
||||
start: segmentData.creditSegments[0].startTime,
|
||||
end: segmentData.creditSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const recap =
|
||||
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
||||
? {
|
||||
start: segmentData.recapSegments[0].startTime,
|
||||
end: segmentData.recapSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
||||
start: seg.startTime,
|
||||
end: seg.endTime,
|
||||
}));
|
||||
|
||||
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
||||
start: seg.startTime,
|
||||
end: seg.endTime,
|
||||
}));
|
||||
|
||||
return { intro, credits, recap, commercial, preview };
|
||||
}, [segmentData]);
|
||||
|
||||
// Check which segment we're currently in
|
||||
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
|
||||
// before comparing with segment times (which are in seconds from the autoskip API)
|
||||
const currentSegment = useMemo(() => {
|
||||
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
||||
return { type: "intro" as const, segment: segments.intro };
|
||||
}
|
||||
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
||||
return { type: "credits" as const, segment: segments.credits };
|
||||
}
|
||||
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
||||
return { type: "recap" as const, segment: segments.recap };
|
||||
}
|
||||
for (const commercial of segments.commercial) {
|
||||
if (isWithinSegment(currentProgressMs, commercial)) {
|
||||
return { type: "commercial" as const, segment: commercial };
|
||||
}
|
||||
}
|
||||
for (const preview of segments.preview) {
|
||||
if (isWithinSegment(currentProgressMs, preview)) {
|
||||
return { type: "preview" as const, segment: preview };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [currentProgressMs, segments]);
|
||||
|
||||
// Skip functions
|
||||
const skipIntro = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.intro) {
|
||||
await seekFn(segments.intro.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.intro],
|
||||
);
|
||||
|
||||
const skipCredits = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.credits) {
|
||||
await seekFn(segments.credits.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.credits],
|
||||
);
|
||||
|
||||
const skipSegment = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (currentSegment?.segment) {
|
||||
await seekFn(currentSegment.segment.end * 1000);
|
||||
}
|
||||
},
|
||||
[currentSegment],
|
||||
);
|
||||
|
||||
// Auto-skip logic based on settings
|
||||
const shouldAutoSkip = useMemo(() => {
|
||||
if (!currentSegment) return false;
|
||||
|
||||
switch (currentSegment.type) {
|
||||
case "intro":
|
||||
return settings?.skipIntro === "auto";
|
||||
case "credits":
|
||||
return settings?.skipOutro === "auto";
|
||||
case "recap":
|
||||
return settings?.skipRecap === "auto";
|
||||
case "commercial":
|
||||
return settings?.skipCommercial === "auto";
|
||||
case "preview":
|
||||
return settings?.skipPreview === "auto";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
currentSegment,
|
||||
settings?.skipIntro,
|
||||
settings?.skipOutro,
|
||||
settings?.skipRecap,
|
||||
settings?.skipCommercial,
|
||||
settings?.skipPreview,
|
||||
]);
|
||||
|
||||
return {
|
||||
segments,
|
||||
currentSegment,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
skipSegment,
|
||||
shouldAutoSkip,
|
||||
hasIntro: !!segments.intro,
|
||||
hasCredits: !!segments.credits,
|
||||
};
|
||||
};
|
||||
@@ -60,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
|
||||
return (
|
||||
<MoreMoviesWithActor
|
||||
key={`${person.Id}-${idx}`}
|
||||
key={person.Id}
|
||||
currentItem={item}
|
||||
actorId={person.Id}
|
||||
actorName={person.Name}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Player-agnostic "next episode" countdown card. The parent owns the timer and
|
||||
* positioning — this component only renders the next episode's poster, title,
|
||||
* the remaining seconds, and the Play-now / Cancel actions.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface AutoplayCountdownProps {
|
||||
/** The episode that will play next. */
|
||||
nextEpisode: BaseItemDto;
|
||||
/** Poster image URL for the next episode, or null. */
|
||||
posterUrl: string | null;
|
||||
/** Seconds left before the next episode plays. */
|
||||
secondsRemaining: number;
|
||||
/** Play the next episode immediately. */
|
||||
onPlayNow: () => void;
|
||||
/** Cancel autoplay — the next episode will not play. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AutoplayCountdown({
|
||||
nextEpisode,
|
||||
posterUrl,
|
||||
secondsRemaining,
|
||||
onPlayNow,
|
||||
onCancel,
|
||||
}: AutoplayCountdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
width: 320,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "rgba(20, 20, 20, 0.94)",
|
||||
}}
|
||||
>
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: 62, height: 93, borderRadius: 6 }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, justifyContent: "space-between" }}>
|
||||
<View style={{ gap: 2 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("player.up_next")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{nextEpisode.Name}
|
||||
</Text>
|
||||
<Text style={{ color: "#a855f7", fontSize: 13 }}>
|
||||
{t("player.next_episode_in", { seconds: secondsRemaining })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
|
||||
<Pressable
|
||||
onPress={onPlayNow}
|
||||
accessibilityRole='button'
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#a855f7",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||
{t("player.play_now")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
accessibilityRole='button'
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#333",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||
{t("player.cancel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -1,62 +1,18 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Switch, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
|
||||
auto: "Automatic (recommended)",
|
||||
"force-hevc": "Force HEVC / H265",
|
||||
"force-h264": "Force H264",
|
||||
};
|
||||
|
||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const profileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: (
|
||||
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
|
||||
).map((mode) => ({
|
||||
type: "radio" as const,
|
||||
label: PROFILE_LABELS[mode],
|
||||
value: mode,
|
||||
selected: (settings.chromecastProfile ?? "auto") === mode,
|
||||
onPress: () => updateSettings({ chromecastProfile: mode }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings.chromecastProfile, updateSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
<ListItem
|
||||
title={"Profile"}
|
||||
subtitle={
|
||||
"Automatic picks codecs per device. Override only if needed."
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={profileOptions}
|
||||
title={"Chromecast profile"}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
<ListItem title={"Enable H265 for Chromecast"}>
|
||||
<Switch
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -98,48 +96,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
|
||||
// valid selection even if an out-of-range value was stored previously.
|
||||
const autoplayCountdown = Math.min(
|
||||
60,
|
||||
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
|
||||
);
|
||||
const castAutoplayCountdown = Math.min(
|
||||
60,
|
||||
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
|
||||
);
|
||||
|
||||
const autoplayCountdownOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||
type: "radio" as const,
|
||||
label: String(seconds),
|
||||
value: String(seconds),
|
||||
selected: seconds === autoplayCountdown,
|
||||
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[autoplayCountdown, updateSettings],
|
||||
);
|
||||
|
||||
const castAutoplayCountdownOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||
type: "radio" as const,
|
||||
label: String(seconds),
|
||||
value: String(seconds),
|
||||
selected: seconds === castAutoplayCountdown,
|
||||
onPress: () =>
|
||||
updateSettings({ castAutoplayCountdownSeconds: seconds }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[castAutoplayCountdown, updateSettings],
|
||||
);
|
||||
|
||||
const playbackSpeedOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -295,57 +251,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* Media Segment Skip Settings */}
|
||||
<ListItem
|
||||
title={t("home.settings.other.segment_skip_settings")}
|
||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
||||
onPress={() => router.push("/settings/segment-skip/page")}
|
||||
>
|
||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoplayCountdownOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={castAutoplayCountdownOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{castAutoplayCountdown}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
@@ -366,6 +271,3 @@ const AUTOPLAY_EPISODES_COUNT = (
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
|
||||
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
|
||||
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];
|
||||
|
||||
259
components/syncplay/GroupSelectionMenu.tsx
Normal file
259
components/syncplay/GroupSelectionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
206
components/syncplay/SyncPlayActionIcon.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
97
components/syncplay/SyncPlayButton.tsx
Normal file
97
components/syncplay/SyncPlayButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
53
components/syncplay/SyncPlaySpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
components/syncplay/index.ts
Normal file
8
components/syncplay/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* SyncPlay UI Components
|
||||
*/
|
||||
|
||||
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
|
||||
export { SyncPlayButton } from "./SyncPlayButton";
|
||||
export { SyncPlaySpinner } from "./SyncPlaySpinner";
|
||||
@@ -1,22 +1,20 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
ChapterInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
@@ -37,14 +35,11 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
skipButtonText: string;
|
||||
showSkipCreditButton: boolean;
|
||||
skipCreditButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
api?: Api | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
handleControlsInteraction: () => void;
|
||||
@@ -80,9 +75,6 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Chapter props
|
||||
chapterPositions?: number[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -95,14 +87,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
skipButtonText,
|
||||
showSkipCreditButton,
|
||||
skipCreditButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
api,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
handleControlsInteraction,
|
||||
@@ -119,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
@@ -133,83 +121,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
);
|
||||
const hasChapters = chapterMarkerList.length > 1;
|
||||
|
||||
// Autoplay overlay: shown under the same condition the old countdown button used.
|
||||
const autoplayAllowed =
|
||||
settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||
|
||||
const showNextEpisodeCountdown =
|
||||
autoplayAllowed &&
|
||||
(!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000);
|
||||
|
||||
const [secondsRemaining, setSecondsRemaining] = useState(
|
||||
settings.autoplayCountdownSeconds,
|
||||
);
|
||||
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Keep a stable ref to the autoplay handler so the timer effect does not
|
||||
// restart when the handler identity changes.
|
||||
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
|
||||
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNextEpisodeCountdown || autoplayCancelled) {
|
||||
// Either the show-condition flipped off OR the user cancelled.
|
||||
// In both cases, stop the running timer immediately so autoplay
|
||||
// can't fire after Cancel was pressed.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
// Only reset cancellation + seconds when the show-condition itself
|
||||
// flipped off — a fresh credits/end-of-video window then starts a
|
||||
// brand-new countdown. If we got here because autoplayCancelled
|
||||
// just flipped true, keep it true so the countdown stays stopped.
|
||||
if (!showNextEpisodeCountdown) {
|
||||
setAutoplayCancelled(false);
|
||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSecondsRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
autoPlayHandlerRef.current();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
showNextEpisodeCountdown,
|
||||
autoplayCancelled,
|
||||
settings.autoplayCountdownSeconds,
|
||||
]);
|
||||
|
||||
const nextEpisodePosterUrl = useMemo(
|
||||
() =>
|
||||
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
|
||||
[api, nextItem],
|
||||
);
|
||||
|
||||
// Current chapter name for the always-visible header label (live playback).
|
||||
const currentChapterName = useMemo(
|
||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||
@@ -231,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
bottom:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? Math.max(insets.bottom - 17, 0)
|
||||
: 0,
|
||||
right: insets.right,
|
||||
left: insets.left,
|
||||
bottom: Math.max(insets.bottom - 17, 0),
|
||||
},
|
||||
]}
|
||||
className={"flex flex-col px-2"}
|
||||
@@ -273,21 +180,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText={skipButtonText}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -297,16 +193,34 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText={skipCreditButtonText}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
|
||||
<AutoplayCountdown
|
||||
nextEpisode={nextItem}
|
||||
posterUrl={nextEpisodePosterUrl}
|
||||
secondsRemaining={secondsRemaining}
|
||||
onPlayNow={handleNextEpisodeManual}
|
||||
onCancel={() => setAutoplayCancelled(true)}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center ml-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { FC } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
@@ -42,15 +43,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
goToNextChapter,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
@@ -121,15 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
|
||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
{!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
<SyncPlayActionIcon
|
||||
size={ICON_SIZES.CENTER}
|
||||
fallback={
|
||||
!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -7,14 +7,12 @@ import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export interface ContinueWatchingOverlayProps {
|
||||
goToNextItem: (options: {
|
||||
isAutoPlay: boolean;
|
||||
resetWatchCount: boolean;
|
||||
}) => void;
|
||||
/** Invoked when the user confirms they want to keep watching. */
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
goToNextItem,
|
||||
onContinue,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -29,13 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
<Text className='text-2xl font-bold text-white py-4 '>
|
||||
Are you still watching ?
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
|
||||
}}
|
||||
color={"purple"}
|
||||
className='my-4 w-2/3'
|
||||
>
|
||||
<Button onPress={onContinue} color={"purple"} className='my-4 w-2/3'>
|
||||
{t("player.continue_watching")}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -4,15 +4,7 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -23,18 +15,16 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -51,9 +41,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
// No-op function to avoid creating new references on every render
|
||||
const noop = () => {};
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
isPlaying: boolean;
|
||||
@@ -115,31 +102,11 @@ export const Controls: FC<Props> = ({
|
||||
transcodeReasons,
|
||||
}) => {
|
||||
const offline = useOfflineMode();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
|
||||
const playingRef = useRef(isPlaying);
|
||||
useEffect(() => {
|
||||
playingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -248,7 +215,6 @@ export const Controls: FC<Props> = ({
|
||||
hasNextChapter,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
chapterPositions,
|
||||
} = useChapterNavigation({
|
||||
chapters: item.Chapters,
|
||||
progress,
|
||||
@@ -346,243 +312,50 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
downloadedFiles,
|
||||
api,
|
||||
downloadedFiles,
|
||||
);
|
||||
|
||||
// Convert milliseconds to seconds for segment comparison
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
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
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
// Cancel any pending play call to avoid race conditions
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
// Read latest isPlaying from ref to avoid stale closure
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
if (playingRef.current) {
|
||||
play();
|
||||
}
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
/*
|
||||
* Single source of truth for next / previous / picker / autoplay
|
||||
* navigation. Handles SyncPlay dispatch, autoplay count gating,
|
||||
* platform-appropriate local navigation, and offline param injection.
|
||||
*/
|
||||
const {
|
||||
goToNextItem: handleNextEpisodeManual,
|
||||
goToPreviousItem: handlePreviousItem,
|
||||
goToItem: handleGoToItem,
|
||||
handleAutoPlayNext: handleNextEpisodeAutoPlay,
|
||||
handleContinueWatching,
|
||||
} = usePlayerItemNavigation({
|
||||
nextItem,
|
||||
previousItem,
|
||||
mediaSource,
|
||||
currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
||||
currentSubtitleIndex: subtitleIndex
|
||||
? Number.parseInt(subtitleIndex, 10)
|
||||
: undefined,
|
||||
bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
setShowControls(false);
|
||||
setShowAudioSlider(false);
|
||||
@@ -610,7 +383,7 @@ export const Controls: FC<Props> = ({
|
||||
<EpisodeList
|
||||
item={item}
|
||||
close={() => setEpisodeView(false)}
|
||||
goToItem={goToItemCommon}
|
||||
goToItem={handleGoToItem}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -644,8 +417,8 @@ export const Controls: FC<Props> = ({
|
||||
mediaSource={mediaSource}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
switchOnEpisodeMode={switchOnEpisodeMode}
|
||||
goToPreviousItem={goToPreviousItem}
|
||||
goToNextItem={goToNextItem}
|
||||
goToPreviousItem={handlePreviousItem}
|
||||
goToNextItem={handleNextEpisodeManual}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
aspectRatio={aspectRatio}
|
||||
@@ -691,14 +464,11 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
skipButtonText={skipButtonText}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipCreditButtonText={skipCreditButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
api={api}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
handleControlsInteraction={handleControlsInteraction}
|
||||
@@ -715,13 +485,12 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
chapterPositions={chapterPositions}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||
<ContinueWatchingOverlay onContinue={handleContinueWatching} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
paddingTop:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
paddingLeft:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
paddingRight:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -5,12 +5,11 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -25,7 +24,7 @@ interface HeaderControlsProps {
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
switchOnEpisodeMode: () => void;
|
||||
goToPreviousItem: () => void;
|
||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||
goToNextItem: () => void;
|
||||
previousItem?: BaseItemDto | null;
|
||||
nextItem?: BaseItemDto | null;
|
||||
aspectRatio?: AspectRatio;
|
||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { orientation, lockOrientation } = useOrientation();
|
||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
@@ -175,7 +172,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
)}
|
||||
{nextItem && (
|
||||
<TouchableOpacity
|
||||
onPress={() => goToNextItem({ isAutoPlay: false })}
|
||||
onPress={() => goToNextItem()}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
|
||||
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||
onFinish?: () => void;
|
||||
onPress?: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
onFinish,
|
||||
onPress,
|
||||
show,
|
||||
...props
|
||||
}) => {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
progress.value = 0;
|
||||
progress.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 10000, // 10 seconds
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished && onFinish) {
|
||||
runOnJS(onFinish)();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||
return () => {
|
||||
cancelAnimation(progress);
|
||||
};
|
||||
}
|
||||
}, [show, onFinish]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${progress.value * 100}%`,
|
||||
backgroundColor: Colors.primary,
|
||||
};
|
||||
});
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
|
||||
{...props}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Animated.View style={animatedStyle} />
|
||||
<View className='px-3 py-3'>
|
||||
<Text numberOfLines={1} className='text-center font-bold'>
|
||||
{t("player.next_episode")}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextEpisodeCountDownButton;
|
||||
@@ -16,8 +16,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
|
||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
left: Math.max(insets.left, 48) + 20,
|
||||
}
|
||||
: {
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Chromecast Cast Test Matrix
|
||||
|
||||
Manual verification for the device-profile work. Run each row by casting the
|
||||
matching media from the app to a physical Chromecast and recording the result.
|
||||
|
||||
**Test device:** ___________________ (model name as reported by the app)
|
||||
**App build / commit:** ___________________
|
||||
**Date:** ___________________
|
||||
|
||||
## How to run
|
||||
|
||||
1. Pick a library item matching the row's codec / audio / container.
|
||||
2. Cast it. Note whether it direct-plays or transcodes (server logs show
|
||||
`Video is being transcoded` vs `Video is being direct played`).
|
||||
3. Record the load result: OK / 2100 / infinite-loading / other.
|
||||
|
||||
## Matrix
|
||||
|
||||
| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | |
|
||||
| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | |
|
||||
| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | |
|
||||
| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | |
|
||||
| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | |
|
||||
| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | |
|
||||
| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | |
|
||||
| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | |
|
||||
| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | |
|
||||
| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | |
|
||||
|
||||
## Outcome
|
||||
|
||||
- Highest video bitrate that loads reliably on the test device: ___________
|
||||
-> update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in
|
||||
`utils/casting/capabilities.ts` accordingly.
|
||||
- Confirmed cause of issue #1423 (<= 2 Mb/s): ___________
|
||||
- Confirmed cause of the 5.1 crash (#1085): ___________
|
||||
- Cases where downgrade-on-failure retry rescued playback: ___________
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* Cast autoplay watcher.
|
||||
*
|
||||
* Always-mounted hook: subscribes to the Chromecast `mediaStatus`, captures the
|
||||
* currently-playing episode while playback is active, and on either
|
||||
* (a) playback entering the Outro segment (when `skipOutro !== "auto"`), or
|
||||
* (b) `IDLE + FINISHED` (hard end of media),
|
||||
* starts a cancellable countdown via `castAutoplayAtom` and ultimately loads
|
||||
* the next episode on the cast.
|
||||
*
|
||||
* The countdown atom is driven here; the casting-player overlay reads it.
|
||||
* Cancellation (overlay's Cancel button) sets the atom to `null` externally;
|
||||
* the watcher reacts by clearing its interval and refusing to retrigger for
|
||||
* the same item.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
MediaPlayerIdleReason,
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
|
||||
/**
|
||||
* Cached next-episode resolution, keyed by the captured (seriesId, currentEpisodeId)
|
||||
* pair so the network calls are not repeated on every `mediaStatus` tick.
|
||||
*/
|
||||
interface NextEpisodeCache {
|
||||
seriesId: string;
|
||||
currentEpisodeId: string;
|
||||
nextEpisode: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export interface ShouldStartCountdownParams {
|
||||
playerState: MediaPlayerState | undefined;
|
||||
idleReason: MediaPlayerIdleReason | undefined;
|
||||
currentPositionMs: number;
|
||||
outroStartMs: number | null;
|
||||
outroEndMs: number | null;
|
||||
skipOutro: string;
|
||||
alreadyTriggered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision helper: should the countdown start *right now*?
|
||||
* Exported for testability.
|
||||
*/
|
||||
export const shouldStartCountdown = ({
|
||||
playerState,
|
||||
idleReason,
|
||||
currentPositionMs,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
skipOutro,
|
||||
alreadyTriggered,
|
||||
}: ShouldStartCountdownParams): boolean => {
|
||||
if (alreadyTriggered) return false;
|
||||
|
||||
// (b) hard end of media — fires regardless of segment availability.
|
||||
if (
|
||||
playerState === MediaPlayerState.IDLE &&
|
||||
idleReason === MediaPlayerIdleReason.FINISHED
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// (a) playback inside Outro segment, and Outro is not already auto-skipped.
|
||||
if (
|
||||
skipOutro !== "auto" &&
|
||||
outroStartMs != null &&
|
||||
outroEndMs != null &&
|
||||
currentPositionMs >= outroStartMs &&
|
||||
currentPositionMs < outroEndMs
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useCastAutoplay = (): void => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [autoplayState, setAutoplayState] = useAtom(castAutoplayAtom);
|
||||
|
||||
// Continuously captured currently-playing item (full BaseItemDto, fetched
|
||||
// from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays.
|
||||
const capturedItemRef = useRef<BaseItemDto | null>(null);
|
||||
const capturedItemIdRef = useRef<string | null>(null);
|
||||
// State mirror of the captured item id so downstream effects/hooks re-run
|
||||
// *after* the async getItem resolves — depending on `contentId` directly
|
||||
// would fire them before the ref is populated and they'd read stale data.
|
||||
const [capturedItemId, setCapturedItemId] = useState<string | null>(null);
|
||||
|
||||
// Cached next-episode resolution per (seriesId, currentEpisodeId).
|
||||
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(null);
|
||||
|
||||
// Last item id we triggered a countdown for. Reset when captured item changes
|
||||
// so the same finished episode does not retrigger.
|
||||
const triggeredForItemIdRef = useRef<string | null>(null);
|
||||
|
||||
// Countdown interval handle.
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Track whether the atom transitioned to null while a countdown is running —
|
||||
// that means the overlay cancelled, so we must not retrigger for this item.
|
||||
const autoplayStateRef = useRef(autoplayState);
|
||||
autoplayStateRef.current = autoplayState;
|
||||
|
||||
// Latest settings snapshot reachable from the interval / load callback
|
||||
// without re-creating the interval on every settings change.
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
const updateSettingsRef = useRef(updateSettings);
|
||||
updateSettingsRef.current = updateSettings;
|
||||
|
||||
const apiRef = useRef(api);
|
||||
apiRef.current = api;
|
||||
const userRef = useRef(user);
|
||||
userRef.current = user;
|
||||
const remoteMediaClientRef = useRef(remoteMediaClient);
|
||||
remoteMediaClientRef.current = remoteMediaClient;
|
||||
const castDeviceRef = useRef(castDevice);
|
||||
castDeviceRef.current = castDevice;
|
||||
|
||||
const contentId = mediaStatus?.mediaInfo?.contentId ?? null;
|
||||
|
||||
// --- 1. Capture the currently-playing item, full BaseItemDto. ---
|
||||
useEffect(() => {
|
||||
if (!contentId || !api || !user?.Id) {
|
||||
// No active content: clear all captured state so downstream effects /
|
||||
// useSegments stop using a stale previous-item id.
|
||||
capturedItemRef.current = null;
|
||||
capturedItemIdRef.current = null;
|
||||
setCapturedItemId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the captured id changed, reset the trigger guard immediately — the
|
||||
// user moved to another episode, and that new episode should be eligible.
|
||||
if (capturedItemIdRef.current !== contentId) {
|
||||
triggeredForItemIdRef.current = null;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem(
|
||||
{ itemId: contentId, userId: user.Id! },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (cancelled) return;
|
||||
capturedItemRef.current = res.data;
|
||||
capturedItemIdRef.current = contentId;
|
||||
// Publish the captured id as state *after* the ref is set, so the
|
||||
// next-episode-resolve effect (keyed on this state) sees a populated
|
||||
// ref by the time it runs.
|
||||
setCapturedItemId(contentId);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
// Non-fatal: keep whatever we last captured.
|
||||
console.error("[useCastAutoplay] Failed to fetch item:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [contentId, api, user?.Id]);
|
||||
|
||||
// --- 2. Resolve next episode (cached per series+episode). ---
|
||||
// This effect runs whenever the captured item id changes; the cache key
|
||||
// prevents refetching on every mediaStatus tick.
|
||||
useEffect(() => {
|
||||
const item = capturedItemRef.current;
|
||||
if (!item || !api || !user) return;
|
||||
if (item.Type !== "Episode") {
|
||||
nextEpisodeCacheRef.current = null;
|
||||
return;
|
||||
}
|
||||
const seriesId = item.SeriesId;
|
||||
const currentEpisodeId = item.Id;
|
||||
if (!seriesId || !currentEpisodeId) {
|
||||
nextEpisodeCacheRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = nextEpisodeCacheRef.current;
|
||||
if (
|
||||
cached &&
|
||||
cached.seriesId === seriesId &&
|
||||
cached.currentEpisodeId === currentEpisodeId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const episodes = await fetchSeriesEpisodes(api, user, seriesId);
|
||||
if (cancelled) return;
|
||||
nextEpisodeCacheRef.current = {
|
||||
seriesId,
|
||||
currentEpisodeId,
|
||||
nextEpisode: findNextEpisode(episodes, currentEpisodeId),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to resolve next episode:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Depend on the *state* mirror of the captured id rather than `contentId`
|
||||
// directly: `contentId` flips synchronously on the new episode, but
|
||||
// `capturedItemRef.current` is only populated after the async getItem
|
||||
// resolves. Keying on `capturedItemId` (set right after the ref write)
|
||||
// guarantees the ref points at the new item by the time we read it here.
|
||||
}, [capturedItemId, api, user]);
|
||||
|
||||
// --- 3. Media segments for the captured item (Outro). ---
|
||||
// Matches `useChromecastSegments`: cast playback is online, no downloaded
|
||||
// files context to thread through.
|
||||
const { data: segmentData } = useSegments(
|
||||
capturedItemId ?? "",
|
||||
false,
|
||||
undefined,
|
||||
api,
|
||||
);
|
||||
|
||||
const outroSegment = segmentData?.creditSegments?.[0] ?? null;
|
||||
const outroStartMs = outroSegment ? outroSegment.startTime * 1000 : null;
|
||||
const outroEndMs = outroSegment ? outroSegment.endTime * 1000 : null;
|
||||
|
||||
// --- 4. Trigger detection. ---
|
||||
useEffect(() => {
|
||||
// Master gate: setting must allow autoplay, and a countdown must not be
|
||||
// already running. The atom drives the countdown; an active atom means
|
||||
// we already triggered (possibly via overlay's Play now).
|
||||
if (!settings.autoPlayNextEpisode) return;
|
||||
if (autoplayState !== null) return;
|
||||
|
||||
const maxValue = settings.maxAutoPlayEpisodeCount.value;
|
||||
if (maxValue !== -1 && settings.autoPlayEpisodeCount >= maxValue) return;
|
||||
|
||||
const capturedItem = capturedItemRef.current;
|
||||
const capturedItemId = capturedItemIdRef.current;
|
||||
if (!capturedItem || !capturedItemId) return;
|
||||
if (capturedItem.Type !== "Episode") return;
|
||||
|
||||
const cached = nextEpisodeCacheRef.current;
|
||||
if (
|
||||
!cached ||
|
||||
cached.currentEpisodeId !== capturedItemId ||
|
||||
!cached.nextEpisode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextEpisode = cached.nextEpisode;
|
||||
|
||||
const currentPositionMs = (mediaStatus?.streamPosition ?? 0) * 1000;
|
||||
|
||||
const should = shouldStartCountdown({
|
||||
playerState: mediaStatus?.playerState as MediaPlayerState | undefined,
|
||||
idleReason: mediaStatus?.idleReason as MediaPlayerIdleReason | undefined,
|
||||
currentPositionMs,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
skipOutro: settings.skipOutro,
|
||||
alreadyTriggered: triggeredForItemIdRef.current === capturedItemId,
|
||||
});
|
||||
|
||||
if (!should) return;
|
||||
|
||||
triggeredForItemIdRef.current = capturedItemId;
|
||||
setAutoplayState({
|
||||
nextEpisode,
|
||||
secondsRemaining: settings.castAutoplayCountdownSeconds,
|
||||
});
|
||||
// The countdown interval is started by the effect below (reacts to the
|
||||
// atom transitioning to non-null), so this effect stays pure-decide.
|
||||
}, [
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.idleReason,
|
||||
mediaStatus?.streamPosition,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
settings.autoPlayNextEpisode,
|
||||
settings.autoPlayEpisodeCount,
|
||||
settings.maxAutoPlayEpisodeCount,
|
||||
settings.castAutoplayCountdownSeconds,
|
||||
settings.skipOutro,
|
||||
autoplayState,
|
||||
setAutoplayState,
|
||||
]);
|
||||
|
||||
// --- 5. Run countdown interval whenever atom is non-null. ---
|
||||
// Starting/stopping is driven by the atom value, so an external Cancel
|
||||
// (overlay) that sets the atom to null naturally tears the interval down.
|
||||
useEffect(() => {
|
||||
if (autoplayState === null) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only start an interval if one is not already running.
|
||||
if (intervalRef.current) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
// Read latest atom value from ref to decide what to do next.
|
||||
const current = autoplayStateRef.current;
|
||||
if (current === null) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = current.secondsRemaining - 1;
|
||||
if (next > 0) {
|
||||
setAutoplayState({ ...current, secondsRemaining: next });
|
||||
return;
|
||||
}
|
||||
|
||||
// Time's up — load the next episode and clear.
|
||||
// Snapshot what we need; clear the interval and atom synchronously to
|
||||
// avoid double-fire.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const episodeToLoad = current.nextEpisode;
|
||||
setAutoplayState(null);
|
||||
|
||||
const apiLocal = apiRef.current;
|
||||
const userLocal = userRef.current;
|
||||
const clientLocal = remoteMediaClientRef.current;
|
||||
const deviceLocal = castDeviceRef.current;
|
||||
const settingsLocal = settingsRef.current;
|
||||
|
||||
if (!apiLocal || !userLocal?.Id || !clientLocal || !episodeToLoad?.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror `useCastEpisodes.loadEpisode` exactly — same arguments,
|
||||
// same start-position derivation.
|
||||
(async () => {
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episodeToLoad.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client: clientLocal,
|
||||
device: deviceLocal,
|
||||
api: apiLocal,
|
||||
item: episodeToLoad,
|
||||
userId: userLocal.Id!,
|
||||
profileMode: settingsLocal.chromecastProfile,
|
||||
maxBitrateSetting: settingsLocal.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to load next episode:",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the freshest count at the moment of the write — the
|
||||
// overlay's "Play now" can reset this to 0 in parallel, and using
|
||||
// a snapshot taken before the await would clobber that reset.
|
||||
updateSettingsRef.current({
|
||||
autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1,
|
||||
});
|
||||
toast("Playing next episode");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to load next episode:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoplayState, setAutoplayState]);
|
||||
|
||||
// --- 6. Final unmount cleanup is covered by the interval effect's
|
||||
// return; nothing else to do here.
|
||||
};
|
||||
|
||||
export default useCastAutoplay;
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ImperativeRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface UseCastDismissGestureParams {
|
||||
router: ImperativeRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swipe-down-to-dismiss gesture cluster for the casting player modal.
|
||||
* Owns the `translateY`/`context` shared values, the pan gesture, the animated
|
||||
* style, and the `dismissModal` callback (also invoked by the header button).
|
||||
*/
|
||||
export function useCastDismissGesture({ router }: UseCastDismissGestureParams) {
|
||||
// Swipe down to dismiss gesture
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ y: 0 });
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
// Navigate immediately without animation to prevent crashes
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { y: translateY.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Only allow downward swipes from top of screen
|
||||
if (event.translationY > 0) {
|
||||
translateY.value = context.value.y + event.translationY;
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
// Dismiss if swiped down more than 150px or fast swipe
|
||||
if (event.translationY > 150 || event.velocityY > 600) {
|
||||
// Animate down and dismiss
|
||||
translateY.value = withSpring(
|
||||
1000,
|
||||
{
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
},
|
||||
() => {
|
||||
runOnJS(dismissModal)();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Spring back to position
|
||||
translateY.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return { panGesture, animatedStyle, dismissModal };
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Device, RemoteMediaClient } from "react-native-google-cast";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||
|
||||
interface UseCastEpisodesParams {
|
||||
api: Api | null;
|
||||
user: UserDto | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
castDevice: Device | null;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
interface UseCastEpisodesResult {
|
||||
episodes: BaseItemDto[];
|
||||
nextEpisode: BaseItemDto | null;
|
||||
seasonData: BaseItemDto | null;
|
||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||
/**
|
||||
* Id of the episode currently being loaded onto the cast device, or null
|
||||
* when no load is pending. The cast `customData` (and thus `currentItem`)
|
||||
* lags behind the load, so consumers use this to detect the stale window
|
||||
* between a `loadEpisode` call and the cast reporting the new episode.
|
||||
*/
|
||||
loadingEpisodeId: string | null;
|
||||
}
|
||||
|
||||
export function useCastEpisodes({
|
||||
api,
|
||||
user,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings,
|
||||
}: UseCastEpisodesParams): UseCastEpisodesResult {
|
||||
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
||||
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
||||
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
||||
// Target episode id while a load is in flight; cleared once it resolves.
|
||||
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
|
||||
|
||||
// Load a different episode on the Chromecast
|
||||
const loadEpisode = useCallback(
|
||||
async (episode: BaseItemDto) => {
|
||||
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
|
||||
|
||||
setLoadingEpisodeId(episode.Id);
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: episode,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load episode:",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to load episode:", error);
|
||||
} finally {
|
||||
// Clear regardless of outcome: on success `currentItem` catches up via
|
||||
// customData; on failure the stale guard must not stay stuck.
|
||||
setLoadingEpisodeId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
],
|
||||
);
|
||||
|
||||
// Fetch season data for season poster
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentItem?.Type !== "Episode" ||
|
||||
!currentItem.SeasonId ||
|
||||
!api ||
|
||||
!user?.Id
|
||||
)
|
||||
return;
|
||||
|
||||
const fetchSeasonData = async () => {
|
||||
try {
|
||||
const userLibraryApi = getUserLibraryApi(api);
|
||||
const response = await userLibraryApi.getItem({
|
||||
itemId: currentItem.SeasonId!,
|
||||
userId: user.Id!,
|
||||
});
|
||||
setSeasonData(response.data);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to fetch season data:", error);
|
||||
setSeasonData(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSeasonData();
|
||||
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
|
||||
|
||||
// Fetch episodes for TV shows
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentItem?.Type !== "Episode" ||
|
||||
!currentItem.SeriesId ||
|
||||
!api ||
|
||||
!user
|
||||
)
|
||||
return;
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
// Fetch ALL episodes from ALL seasons (no season filter).
|
||||
const episodeList = await fetchSeriesEpisodes(
|
||||
api,
|
||||
user,
|
||||
currentItem.SeriesId!,
|
||||
);
|
||||
setEpisodes(episodeList);
|
||||
setNextEpisode(findNextEpisode(episodeList, currentItem.Id));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch episodes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEpisodes();
|
||||
}, [
|
||||
currentItem?.Type,
|
||||
currentItem?.SeriesId,
|
||||
currentItem?.SeasonId,
|
||||
currentItem?.Id,
|
||||
api,
|
||||
user,
|
||||
]);
|
||||
|
||||
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MediaStatus } from "react-native-google-cast";
|
||||
|
||||
interface UseCastPlayerItemParams {
|
||||
api: Api | null;
|
||||
user: UserDto | null;
|
||||
mediaStatus: MediaStatus | null;
|
||||
}
|
||||
|
||||
interface UseCastPlayerItemResult {
|
||||
fetchedItem: BaseItemDto | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export function useCastPlayerItem({
|
||||
api,
|
||||
user,
|
||||
mediaStatus,
|
||||
}: UseCastPlayerItemParams): UseCastPlayerItemResult {
|
||||
// Fetch full item data from Jellyfin by ID
|
||||
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchItemData = async () => {
|
||||
const itemId = mediaStatus?.mediaInfo?.contentId;
|
||||
if (!itemId || !api || !user?.Id) return;
|
||||
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem(
|
||||
{ itemId, userId: user.Id },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!controller.signal.aborted) {
|
||||
setFetchedItem(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
console.error("[Casting Player] Failed to fetch item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItemData();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
||||
|
||||
// Extract item from customData, or use fetched item, or create a minimal fallback
|
||||
const currentItem = useMemo(() => {
|
||||
// Priority 1: Use fetched item from API (most reliable)
|
||||
if (fetchedItem) {
|
||||
return fetchedItem;
|
||||
}
|
||||
|
||||
// Priority 2: Try customData from mediaStatus
|
||||
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
||||
if (
|
||||
customData?.Type &&
|
||||
(customData.ImageTags || customData.MediaSources || customData.Id)
|
||||
) {
|
||||
// Use customData if it has a real Type AND meaningful metadata
|
||||
// (rules out placeholder objects that lack image tags, media sources, or an ID)
|
||||
return customData;
|
||||
}
|
||||
|
||||
// Priority 3: Create minimal fallback while loading
|
||||
if (mediaStatus?.mediaInfo) {
|
||||
const { contentId, metadata } = mediaStatus.mediaInfo;
|
||||
// Derive type from metadata if available, otherwise omit to avoid
|
||||
// misrepresenting episodes as movies
|
||||
let metadataType: string | undefined;
|
||||
if (metadata?.type === "movie") {
|
||||
metadataType = "Movie";
|
||||
} else if (metadata?.type === "tvShow") {
|
||||
metadataType = "Episode";
|
||||
}
|
||||
return {
|
||||
Id: contentId,
|
||||
Name: metadata?.title || "Unknown",
|
||||
...(metadataType ? { Type: metadataType } : {}),
|
||||
ServerId: "",
|
||||
} as BaseItemDto;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [fetchedItem, mediaStatus?.mediaInfo]);
|
||||
|
||||
return { fetchedItem, currentItem };
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
|
||||
interface TrickplayTime {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface UseCastPlayerProgressParams {
|
||||
/** Raw Chromecast media status, or null when no session. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Full item fetched from Jellyfin, used to derive trickplay data. */
|
||||
fetchedItem: BaseItemDto | null;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface UseCastPlayerProgressResult {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: RefObject<boolean>;
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: TrickplayTime;
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: TrickplayTime) => void;
|
||||
/** Current playback progress, in seconds (live-updating). */
|
||||
progress: number;
|
||||
/** Last stable playback position (seconds), for resuming across reloads. */
|
||||
resumePositionRef: RefObject<number>;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress/slider/trickplay cluster for the casting player.
|
||||
* Owns the slider shared values, scrub state, live-progress interpolation,
|
||||
* resume-position tracking, and trickplay preview.
|
||||
*/
|
||||
export function useCastPlayerProgress({
|
||||
mediaStatus,
|
||||
fetchedItem,
|
||||
duration,
|
||||
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
|
||||
// Shared values for progress slider (must be initialized before any early returns)
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Trickplay time display
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
const lastSyncPositionRef = useRef(0);
|
||||
const lastSyncTimestampRef = useRef(Date.now());
|
||||
|
||||
// Last stable playback position (seconds), for resuming across reloads.
|
||||
const resumePositionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync refs whenever mediaStatus provides a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||
lastSyncTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update every second when playing, deriving from last sync point
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||
mediaStatus?.streamPosition !== undefined
|
||||
) {
|
||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||
|
||||
// Track the last stable position so a reload mid-switch resumes correctly.
|
||||
useEffect(() => {
|
||||
const pos = mediaStatus?.streamPosition ?? 0;
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
||||
resumePositionRef.current = pos;
|
||||
}
|
||||
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const progress = liveProgress; // Use live-updating progress
|
||||
|
||||
// Trickplay for seeking preview - use fetched item with full data
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
fetchedItem ?? null,
|
||||
);
|
||||
|
||||
// Update slider max when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Update slider progress when not scrubbing
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress > 0) {
|
||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
return {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Source of truth for the active cast track / quality / version selection.
|
||||
*
|
||||
* Truth = the CastSelection echoed back in the cast media customData. A local
|
||||
* `pending` selection is shown optimistically while a reload re-transcodes, then
|
||||
* cleared once the cast reports it (reconciled) or the reload fails.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { MediaStatus } from "react-native-google-cast";
|
||||
import { resolveSelection, selectionsEqual } from "@/utils/casting/selection";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
|
||||
interface UseCastSelectionParams {
|
||||
currentItem: BaseItemDto | null;
|
||||
mediaStatus: MediaStatus | null | undefined;
|
||||
/** Reload the cast stream with the given selection. Resolves true on success. */
|
||||
reload: (selection: CastSelection) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface UseCastSelectionResult {
|
||||
/** Effective selection: optimistic pending, else cast truth, else default. */
|
||||
currentSelection: CastSelection | null;
|
||||
/** Merge a partial selection, show it optimistically, and reload the stream. */
|
||||
applySelection: (partial: Partial<CastSelection>) => void;
|
||||
}
|
||||
|
||||
export const useCastSelection = ({
|
||||
currentItem,
|
||||
mediaStatus,
|
||||
reload,
|
||||
}: UseCastSelectionParams): UseCastSelectionResult => {
|
||||
const [pending, setPending] = useState<CastSelection | null>(null);
|
||||
|
||||
// Truth: the selection the cast reports as loaded, via customData.
|
||||
const truth =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { selection?: CastSelection }
|
||||
| undefined
|
||||
)?.selection ?? null;
|
||||
|
||||
const currentSelection: CastSelection | null =
|
||||
pending ??
|
||||
truth ??
|
||||
(currentItem ? resolveSelection(currentItem, {}) : null);
|
||||
|
||||
// A new media item invalidates any pending selection from the previous one.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only
|
||||
useEffect(() => {
|
||||
setPending(null);
|
||||
}, [currentItem?.Id]);
|
||||
|
||||
// Reconcile: once the cast reports the pending selection as loaded, clear it.
|
||||
useEffect(() => {
|
||||
if (pending && truth && selectionsEqual(pending, truth)) {
|
||||
setPending(null);
|
||||
}
|
||||
}, [pending, truth]);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(partial: Partial<CastSelection>) => {
|
||||
if (!currentSelection) return;
|
||||
const next: CastSelection = { ...currentSelection, ...partial };
|
||||
setPending(next);
|
||||
reload(next).then((ok) => {
|
||||
if (!ok) setPending(null);
|
||||
});
|
||||
},
|
||||
[currentSelection, reload],
|
||||
);
|
||||
|
||||
return { currentSelection, applySelection };
|
||||
};
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* Unified Casting Hook
|
||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
||||
* Architecture allows for future protocol integrations
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
||||
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
||||
|
||||
/**
|
||||
* Unified hook for managing casting
|
||||
* Extensible architecture supporting multiple protocols
|
||||
*/
|
||||
export const useCasting = (item: BaseItemDto | null) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Chromecast hooks
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
// Local state
|
||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
||||
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
||||
|
||||
// Helper to update both state and ref
|
||||
const updateState = useCallback(
|
||||
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
stateRef.current = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
|
||||
const playSessionId =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { playSessionId?: string }
|
||||
| undefined
|
||||
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
|
||||
|
||||
const playMethod =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { playMethod?: "Transcode" | "DirectPlay" }
|
||||
| undefined
|
||||
)?.playMethod ?? "Transcode";
|
||||
|
||||
// Detect which protocol is active - use CastState for reliable detection
|
||||
const chromecastConnected = castState === CastState.CONNECTED;
|
||||
// Future: Add detection for other protocols here
|
||||
|
||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
||||
? "chromecast"
|
||||
: null;
|
||||
|
||||
const isConnected = chromecastConnected;
|
||||
|
||||
// Update current device
|
||||
useEffect(() => {
|
||||
if (chromecastConnected && castDevice) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
protocol: "chromecast",
|
||||
currentDevice: {
|
||||
id: castDevice.deviceId,
|
||||
name: castDevice.friendlyName || castDevice.deviceId,
|
||||
protocol: "chromecast",
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
protocol: null,
|
||||
currentDevice: null,
|
||||
}));
|
||||
}
|
||||
// Future: Add device detection for other protocols
|
||||
}, [chromecastConnected, castDevice]);
|
||||
|
||||
// Chromecast: Update playback state
|
||||
useEffect(() => {
|
||||
if (activeProtocol === "chromecast" && mediaStatus) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: mediaStatus.playerState === "playing",
|
||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
||||
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
||||
isBuffering: mediaStatus.playerState === "buffering",
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus, activeProtocol, updateState]);
|
||||
|
||||
// Chromecast: Sync volume from mediaStatus
|
||||
useEffect(() => {
|
||||
if (activeProtocol !== "chromecast") return;
|
||||
|
||||
// Sync from mediaStatus when available
|
||||
if (mediaStatus?.volume !== undefined) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
volume: mediaStatus.volume,
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
||||
|
||||
// Progress reporting to Jellyfin (matches native player behavior)
|
||||
// Uses stateRef to read current progress/volume without adding them as deps
|
||||
useEffect(() => {
|
||||
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
||||
|
||||
const playStateApi = getPlaystateApi(api);
|
||||
|
||||
// Report playback start when media begins (only once per item)
|
||||
// Don't require progress > 0 — playback can legitimately start at position 0
|
||||
const currentState = stateRef.current;
|
||||
const isPlaybackActive =
|
||||
currentState.isPlaying ||
|
||||
mediaStatus?.playerState === "playing" ||
|
||||
currentState.progress > 0;
|
||||
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
|
||||
// Set synchronously before async call to prevent race condition duplicates
|
||||
hasReportedStartRef.current = item.Id || null;
|
||||
|
||||
playStateApi
|
||||
.reportPlaybackStart({
|
||||
playbackStartInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: Math.floor(currentState.progress * 10000),
|
||||
PlayMethod: playMethod,
|
||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
||||
IsMuted: currentState.volume === 0,
|
||||
PlaySessionId: playSessionId,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
// Revert on failure so it can be retried
|
||||
hasReportedStartRef.current = null;
|
||||
console.error("[useCasting] Failed to report playback start:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const reportProgress = () => {
|
||||
const s = stateRef.current;
|
||||
// Don't report if no meaningful progress or if buffering
|
||||
if (s.progress <= 0 || s.isBuffering) return;
|
||||
|
||||
const progressMs = Math.floor(s.progress);
|
||||
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
||||
const progressSeconds = Math.floor(progressMs / 1000);
|
||||
|
||||
// When paused, always report to keep server in sync
|
||||
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
||||
if (
|
||||
s.isPlaying &&
|
||||
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedProgressRef.current = progressSeconds;
|
||||
|
||||
playStateApi
|
||||
.reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: progressTicks,
|
||||
IsPaused: !s.isPlaying,
|
||||
PlayMethod: playMethod,
|
||||
VolumeLevel: Math.floor(s.volume * 100),
|
||||
IsMuted: s.volume === 0,
|
||||
PlaySessionId: playSessionId,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useCasting] Failed to report progress:", error);
|
||||
});
|
||||
};
|
||||
|
||||
// Report progress on a fixed interval, reading latest state from ref
|
||||
const interval = setInterval(reportProgress, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
api,
|
||||
item?.Id,
|
||||
user?.Id,
|
||||
isConnected,
|
||||
activeProtocol,
|
||||
playSessionId,
|
||||
playMethod,
|
||||
]);
|
||||
|
||||
// Play/Pause controls
|
||||
const play = useCallback(async () => {
|
||||
if (activeProtocol === "chromecast") {
|
||||
// Check if there's an active media session
|
||||
if (!client || !mediaStatus?.mediaInfo) {
|
||||
console.warn(
|
||||
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.play();
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error playing:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Future: Add play control for other protocols
|
||||
}, [client, mediaStatus, activeProtocol]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (activeProtocol === "chromecast") {
|
||||
try {
|
||||
await client?.pause();
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error pausing:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Future: Add pause control for other protocols
|
||||
}, [client, activeProtocol]);
|
||||
|
||||
const togglePlayPause = useCallback(async () => {
|
||||
if (state.isPlaying) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}, [state.isPlaying, play, pause]);
|
||||
|
||||
// Seek controls
|
||||
const seek = useCallback(
|
||||
async (positionMs: number) => {
|
||||
// Validate position
|
||||
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
||||
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const positionSeconds = positionMs / 1000;
|
||||
|
||||
// Additional validation for Chromecast
|
||||
if (activeProtocol === "chromecast") {
|
||||
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
||||
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
|
||||
const durationSeconds = state.duration / 1000;
|
||||
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
|
||||
console.warn(
|
||||
"[useCasting] Seek position exceeds duration, clamping:",
|
||||
positionSeconds,
|
||||
"->",
|
||||
durationSeconds,
|
||||
);
|
||||
await client?.seek({ position: durationSeconds });
|
||||
return;
|
||||
}
|
||||
await client?.seek({ position: positionSeconds });
|
||||
}
|
||||
// Future: Add seek control for other protocols
|
||||
},
|
||||
[client, activeProtocol, state.duration],
|
||||
);
|
||||
|
||||
const skipForward = useCallback(
|
||||
async (seconds = 10) => {
|
||||
const newPosition = state.progress + seconds * 1000;
|
||||
await seek(Math.min(newPosition, state.duration));
|
||||
},
|
||||
[state.progress, state.duration, seek],
|
||||
);
|
||||
|
||||
const skipBackward = useCallback(
|
||||
async (seconds = 10) => {
|
||||
const newPosition = state.progress - seconds * 1000;
|
||||
await seek(Math.max(newPosition, 0));
|
||||
},
|
||||
[state.progress, seek],
|
||||
);
|
||||
|
||||
// Stop and disconnect
|
||||
const stop = useCallback(
|
||||
async (onStopComplete?: () => void) => {
|
||||
try {
|
||||
if (activeProtocol === "chromecast") {
|
||||
await client?.stop();
|
||||
}
|
||||
// Future: Add stop control for other protocols
|
||||
|
||||
// Report stop to Jellyfin
|
||||
if (api && item?.Id && user?.Id) {
|
||||
const playStateApi = getPlaystateApi(api);
|
||||
await playStateApi.reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: stateRef.current.progress * 10000,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error during stop:", error);
|
||||
} finally {
|
||||
hasReportedStartRef.current = null;
|
||||
setState(DEFAULT_CAST_STATE);
|
||||
stateRef.current = DEFAULT_CAST_STATE;
|
||||
|
||||
// Call callback after stop completes (e.g., to navigate away)
|
||||
if (onStopComplete) {
|
||||
onStopComplete();
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, api, item?.Id, user?.Id, activeProtocol],
|
||||
);
|
||||
|
||||
// Volume control (debounced to reduce API calls)
|
||||
const setVolume = useCallback(
|
||||
(volume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
// Update UI immediately
|
||||
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||
|
||||
// Debounce API call
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
||||
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
||||
// Physical volume buttons are handled automatically by the framework
|
||||
await client.setStreamVolume(clampedVolume).catch(() => {
|
||||
// Ignore errors - session might have ended
|
||||
});
|
||||
}
|
||||
// Future: Add volume control for other protocols
|
||||
}, 300);
|
||||
},
|
||||
[client, activeProtocol, isConnected],
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isConnected,
|
||||
protocol: activeProtocol,
|
||||
isPlaying: state.isPlaying,
|
||||
isBuffering: state.isBuffering,
|
||||
currentItem: item,
|
||||
currentDevice: state.currentDevice,
|
||||
progress: state.progress,
|
||||
duration: state.duration,
|
||||
volume: state.volume,
|
||||
|
||||
// Availability - derived from actual cast state
|
||||
isChromecastAvailable:
|
||||
castState === CastState.CONNECTED ||
|
||||
castState === CastState.CONNECTING ||
|
||||
castState === CastState.NOT_CONNECTED,
|
||||
|
||||
// Raw clients (for advanced operations)
|
||||
remoteMediaClient: client,
|
||||
|
||||
// Controls
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
stop,
|
||||
setVolume,
|
||||
};
|
||||
};
|
||||
18
hooks/useControlsSafeAreaInsets.ts
Normal file
18
hooks/useControlsSafeAreaInsets.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type EdgeInsets,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/**
|
||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||
* returns zero insets so controls can sit flush against the screen edges.
|
||||
*/
|
||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||
* resolves to an empty payload under RN's New Architecture.
|
||||
*/
|
||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let blob: Blob;
|
||||
const tmpFile = new File(
|
||||
Paths.cache,
|
||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||
);
|
||||
try {
|
||||
// Fetch the data from the URL
|
||||
const response = await fetch(url);
|
||||
blob = await response.blob();
|
||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||
idempotent: true,
|
||||
});
|
||||
return await downloaded.base64();
|
||||
} catch (error) {
|
||||
console.warn("Error fetching image:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (tmpFile.exists) tmpFile.delete();
|
||||
}
|
||||
|
||||
// Create a FileReader instance
|
||||
const reader = new FileReader();
|
||||
|
||||
// Convert blob to base64
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Extract the base64 string (remove the data URL prefix)
|
||||
const base64 = reader.result.split(",")[1];
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveImage = useCallback(
|
||||
|
||||
22
hooks/useKeepWebSocketAlive.ts
Normal file
22
hooks/useKeepWebSocketAlive.ts
Normal 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]);
|
||||
}
|
||||
405
hooks/usePlayerItemNavigation.ts
Normal file
405
hooks/usePlayerItemNavigation.ts
Normal 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;
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Dispatches Jellyfin remote-control WebSocket messages to the active
|
||||
* PlaybackController. DisplayMessage is shown as an in-app toast and needs no
|
||||
* controller.
|
||||
*/
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
|
||||
import {
|
||||
mapRemoteCommand,
|
||||
type RemoteWsMessage,
|
||||
} from "@/utils/playback/remoteCommands";
|
||||
|
||||
/** Handle one remote-control message (call it whenever a new WS message arrives). */
|
||||
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
|
||||
const controller = useAtomValue(activePlaybackControllerAtom);
|
||||
const handledRef = useRef<RemoteWsMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage || lastMessage === handledRef.current) return;
|
||||
handledRef.current = lastMessage;
|
||||
const action = mapRemoteCommand(lastMessage);
|
||||
if (!action) return;
|
||||
|
||||
if (action.kind === "displayMessage") {
|
||||
toast(action.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controller) return;
|
||||
|
||||
switch (action.kind) {
|
||||
case "playPause":
|
||||
controller.playPause();
|
||||
break;
|
||||
case "pause":
|
||||
controller.pause();
|
||||
break;
|
||||
case "unpause":
|
||||
controller.unpause();
|
||||
break;
|
||||
case "stop":
|
||||
controller.stop();
|
||||
break;
|
||||
case "seek":
|
||||
controller.seek(action.positionMs);
|
||||
break;
|
||||
case "next":
|
||||
controller.next();
|
||||
break;
|
||||
case "previous":
|
||||
controller.previous();
|
||||
break;
|
||||
case "setVolume":
|
||||
controller.setVolume(action.level);
|
||||
break;
|
||||
case "toggleMute":
|
||||
controller.toggleMute();
|
||||
break;
|
||||
}
|
||||
}, [lastMessage, controller]);
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
|
||||
interface UseSegmentSkipperProps {
|
||||
segments: MediaTimeSegment[];
|
||||
segmentType: SegmentType;
|
||||
currentTime: number;
|
||||
totalDuration?: number;
|
||||
seek: (time: number) => void;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
interface UseSegmentSkipperReturn {
|
||||
currentSegment: MediaTimeSegment | null;
|
||||
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||
*/
|
||||
export const useSegmentSkipper = ({
|
||||
segments,
|
||||
segmentType,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
seek,
|
||||
isPaused,
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||
|
||||
// Get skip mode based on segment type
|
||||
const skipMode = (() => {
|
||||
switch (segmentType) {
|
||||
case "Intro":
|
||||
return settings.skipIntro;
|
||||
case "Outro":
|
||||
return settings.skipOutro;
|
||||
case "Recap":
|
||||
return settings.skipRecap;
|
||||
case "Commercial":
|
||||
return settings.skipCommercial;
|
||||
case "Preview":
|
||||
return settings.skipPreview;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
})();
|
||||
|
||||
// Find current segment
|
||||
const currentSegment =
|
||||
segments.find(
|
||||
(segment) =>
|
||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
||||
) || null;
|
||||
|
||||
// Skip function with optional haptic feedback
|
||||
const skipSegment = useCallback(
|
||||
(notifyOrUseHaptics = true) => {
|
||||
if (!currentSegment || skipMode === "none") return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (
|
||||
segmentType === "Outro" &&
|
||||
totalDuration != null &&
|
||||
Number.isFinite(totalDuration)
|
||||
) {
|
||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||
seek(seekTime);
|
||||
} else {
|
||||
seek(currentSegment.endTime);
|
||||
}
|
||||
|
||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
||||
if (notifyOrUseHaptics) {
|
||||
haptic();
|
||||
}
|
||||
},
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
||||
);
|
||||
// Auto-skip logic when mode is 'auto'
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track segment identity to avoid re-triggering on pause/unpause
|
||||
const segmentId = currentSegment
|
||||
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
||||
: null;
|
||||
|
||||
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
||||
autoSkipTriggeredRef.current = segmentId;
|
||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||
}
|
||||
|
||||
if (!currentSegment) {
|
||||
autoSkipTriggeredRef.current = null;
|
||||
}
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
// Return null segment if skip mode is 'none'
|
||||
return {
|
||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||
skipSegment,
|
||||
};
|
||||
};
|
||||
@@ -17,24 +17,20 @@ interface TrickplayUrl {
|
||||
}
|
||||
|
||||
/** Hook to handle trickplay logic for a given item. */
|
||||
export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200;
|
||||
const isOffline = useGlobalSearchParams().offline === "true";
|
||||
const trickplayInfo = useMemo(
|
||||
() => (item ? getTrickplayInfo(item) : null),
|
||||
[item],
|
||||
);
|
||||
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||
|
||||
/** Generates the trickplay URL for the given item and sheet index.
|
||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||
const getTrickplayUrl = useCallback(
|
||||
(item: BaseItemDto, sheetIndex: number) => {
|
||||
if (!item.Id) return null;
|
||||
// If we are offline, we can use the downloaded item's trickplay data path
|
||||
const downloadedItem = getDownloadedItemById(item.Id);
|
||||
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||
}
|
||||
@@ -49,7 +45,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!trickplayInfo ||
|
||||
!item?.Id ||
|
||||
!item.Id ||
|
||||
now - lastCalculationTime.current < throttleDelay
|
||||
)
|
||||
return;
|
||||
@@ -66,7 +62,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
|
||||
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
||||
const prefetchAllTrickplayImages = useCallback(async () => {
|
||||
if (!trickplayInfo || !item?.Id) return;
|
||||
if (!trickplayInfo || !item.Id) return;
|
||||
const maxConcurrent = 4;
|
||||
const total = trickplayInfo.totalImageSheets;
|
||||
const urls: string[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
interface UseWebSocketProps {
|
||||
@@ -80,9 +81,9 @@ export const useWebSocket = ({
|
||||
playTrailers,
|
||||
}: UseWebSocketProps) => {
|
||||
const router = useRouter();
|
||||
const { lastMessage } = useWebSocketContext();
|
||||
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
||||
const { t } = useTranslation();
|
||||
const { clearLastMessage } = useWebSocketContext();
|
||||
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage) return;
|
||||
@@ -96,6 +97,25 @@ export const useWebSocket = ({
|
||||
| Record<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") {
|
||||
console.log("Command ~ PlayPause");
|
||||
togglePlay();
|
||||
|
||||
@@ -50,6 +50,13 @@ class MpvPlayerModule : Module() {
|
||||
// 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
|
||||
AsyncFunction("play") { view: MpvPlayerView ->
|
||||
view.play()
|
||||
@@ -198,7 +205,7 @@ class MpvPlayerModule : Module() {
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady 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 renderer: MPVLayerRenderer? = null
|
||||
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
pipController?.setPlayerView(textureView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
if (syncPlayDelegated) {
|
||||
onPipPlayRequest(mapOf<String, Any>())
|
||||
return
|
||||
}
|
||||
play()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (syncPlayDelegated) {
|
||||
onPipPauseRequest(mapOf<String, Any>())
|
||||
return
|
||||
}
|
||||
pause()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
@@ -213,7 +220,7 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,17 @@ class MpvPlayerView: ExpoView {
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = 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 cachedPosition: Double = 0
|
||||
@@ -81,7 +92,6 @@ class MpvPlayerView: ExpoView {
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
configureAudioSession()
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -141,21 +151,26 @@ class MpvPlayerView: ExpoView {
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func configureAudioSession() {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback,
|
||||
policy: .longFormAudio,
|
||||
options: []
|
||||
)
|
||||
try audioSession.setActive(true)
|
||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
}
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||
private func tearDownAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||
@@ -270,6 +285,7 @@ class MpvPlayerView: ExpoView {
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
configureAudioSession()
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
@@ -440,6 +456,7 @@ class MpvPlayerView: ExpoView {
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
tearDownAudioSession()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@@ -519,9 +536,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||
nowPlayingManager.activateAudioSession()
|
||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
}
|
||||
}
|
||||
@@ -633,6 +648,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -651,6 +669,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
@@ -660,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
|
||||
func pipControllerPlay(_ controller: PiPController) {
|
||||
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
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
@@ -667,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
|
||||
func pipControllerPause(_ controller: PiPController) {
|
||||
print("PiP pause requested")
|
||||
if syncPlayDelegated {
|
||||
onPipPauseRequest([:])
|
||||
return
|
||||
}
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
@@ -676,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
let seconds = CMTimeGetSeconds(interval)
|
||||
print("PiP skip by interval: \(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
|
||||
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 = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
|
||||
onPictureInPictureChange?: (event: {
|
||||
nativeEvent: OnPictureInPictureChangePayload;
|
||||
}) => 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 {
|
||||
|
||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||
let cachedDb: DownloadsDatabase | null = null;
|
||||
let cacheVersion = 0;
|
||||
|
||||
// Performance optimization: Cache the flattened items array
|
||||
let cachedItems: DownloadedItem[] | null = null;
|
||||
let itemsCacheVersion = -1;
|
||||
|
||||
// Performance optimization: Index for O(1) item lookups by ID
|
||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||
let indexCacheVersion = -1;
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
// Return cached database if available
|
||||
if (cachedDb !== null) {
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
// Parse from storage and cache the result
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||
return cachedDb;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
|
||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||
cachedDb = emptyDb;
|
||||
return emptyDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
// Update the cache with the new database
|
||||
cachedDb = db;
|
||||
// Invalidate derived caches (items array and index)
|
||||
cachedItems = null;
|
||||
itemIndex = null;
|
||||
cacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
// Return cached items if available and up-to-date
|
||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
// Build the items array from the database
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cachedItems = items;
|
||||
itemsCacheVersion = cacheVersion;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* Build or refresh the item index for O(1) lookups
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
function ensureItemIndex(): void {
|
||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
||||
return; // Index is up-to-date
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
// Build new index from all items
|
||||
itemIndex = new Map<string, DownloadedItem>();
|
||||
const items = getAllDownloadedItems();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.item.Id) {
|
||||
itemIndex.set(item.item.Id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
indexCacheVersion = cacheVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
ensureItemIndex();
|
||||
return itemIndex!.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
// saveDownloadsDatabase already invalidates caches
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The Jellyfin item DTO. */
|
||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** The recap segments for the item. */
|
||||
recapSegments?: MediaTimeSegment[];
|
||||
/** The commercial segments for the item. */
|
||||
commercialSegments?: MediaTimeSegment[];
|
||||
/** The preview segments for the item. */
|
||||
previewSegments?: MediaTimeSegment[];
|
||||
/** The user data for the item. */
|
||||
userData: UserData;
|
||||
}
|
||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
|
||||
recapSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
|
||||
commercialSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
|
||||
previewSegments?: MediaTimeSegment[];
|
||||
/** The audio stream index selected for this download */
|
||||
audioStreamIndex?: number;
|
||||
/** The subtitle stream index selected for this download */
|
||||
|
||||
@@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(storedUser);
|
||||
}
|
||||
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
// Dismiss splash screen with cached data immediately,
|
||||
// fetch fresh user data in the background
|
||||
setInitialLoaded(true);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Background fetch failed — app already rendered with cached data
|
||||
console.warn("Background user fetch failed, using cached data:", e);
|
||||
}
|
||||
} else {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,10 +28,6 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
// Conditionally import TrackPlayer only on non-TV platforms
|
||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||
@@ -1625,43 +1621,6 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
settings?.audioLookaheadCount,
|
||||
]);
|
||||
|
||||
// App-wide remote-control surface: wraps the existing music controls so
|
||||
// remote commands can target whatever player is currently active.
|
||||
const isMusicActive = state.currentTrack !== null;
|
||||
|
||||
const playbackController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
playPause: () => {
|
||||
togglePlayPause();
|
||||
},
|
||||
pause: () => {
|
||||
pause();
|
||||
},
|
||||
unpause: () => {
|
||||
resume();
|
||||
},
|
||||
stop: () => {
|
||||
stop();
|
||||
},
|
||||
// TrackPlayer works in seconds; the controller contract is milliseconds.
|
||||
seek: (positionMs: number) => {
|
||||
seek(positionMs / 1000);
|
||||
},
|
||||
next: () => {
|
||||
next();
|
||||
},
|
||||
previous: () => {
|
||||
previous();
|
||||
},
|
||||
// The music player exposes no volume API — keep these as no-ops.
|
||||
setVolume: () => {},
|
||||
toggleMute: () => {},
|
||||
}),
|
||||
[togglePlayPause, pause, resume, stop, seek, next, previous],
|
||||
);
|
||||
|
||||
useRegisterPlaybackController(playbackController, isMusicActive);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
|
||||
191
providers/SyncPlay/Controller.ts
Normal file
191
providers/SyncPlay/Controller.ts
Normal 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;
|
||||
93
providers/SyncPlay/EventEmitter.ts
Normal file
93
providers/SyncPlay/EventEmitter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
383
providers/SyncPlay/Manager.ts
Normal file
383
providers/SyncPlay/Manager.ts
Normal 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;
|
||||
600
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
600
providers/SyncPlay/SyncPlayProvider.tsx
Normal 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;
|
||||
}
|
||||
23
providers/SyncPlay/constants.ts
Normal file
23
providers/SyncPlay/constants.ts
Normal 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);
|
||||
}
|
||||
381
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
381
providers/SyncPlay/cores/PlaybackCore.ts
Normal 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;
|
||||
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
332
providers/SyncPlay/cores/QueueCore.ts
Normal 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;
|
||||
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
220
providers/SyncPlay/cores/TimeSync.ts
Normal 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;
|
||||
13
providers/SyncPlay/index.ts
Normal file
13
providers/SyncPlay/index.ts
Normal 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";
|
||||
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
183
providers/SyncPlay/transport/queueTranslation.ts
Normal 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;
|
||||
}
|
||||
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal file
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal 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]);
|
||||
}
|
||||
88
providers/SyncPlay/types.ts
Normal file
88
providers/SyncPlay/types.ts
Normal 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;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useRemoteControl } from "@/hooks/useRemoteControl";
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
|
||||
@@ -45,6 +44,15 @@ interface WebSocketContextType {
|
||||
lastMessage: WebSocketMessage | null;
|
||||
sendMessage: (message: any) => 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);
|
||||
@@ -55,8 +63,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
// Route Jellyfin remote-control messages to the active player.
|
||||
useRemoteControl(lastMessage);
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const deviceId = useMemo(() => {
|
||||
@@ -66,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||
@@ -222,14 +243,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
IconUrl:
|
||||
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||
PlayableMediaTypes: ["Audio", "Video"],
|
||||
SupportedCommands: [
|
||||
"Play",
|
||||
"DisplayMessage",
|
||||
"SetVolume",
|
||||
"ToggleMute",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
],
|
||||
SupportedCommands: ["Play"],
|
||||
SupportsMediaControl: true,
|
||||
SupportsPersistentIdentifier: true,
|
||||
},
|
||||
@@ -245,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (state: AppStateStatus) => {
|
||||
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...");
|
||||
ws?.close();
|
||||
} 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...");
|
||||
connectWebSocket();
|
||||
}
|
||||
@@ -277,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, []);
|
||||
return (
|
||||
<WebSocketContext.Provider
|
||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||
value={{
|
||||
ws,
|
||||
isConnected,
|
||||
lastMessage,
|
||||
sendMessage,
|
||||
clearLastMessage,
|
||||
acquireKeepAlive,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
|
||||
@@ -27,112 +27,6 @@
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"player": {
|
||||
"skip_intro": "Skip Intro",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"skip_outro": "Skip Outro",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_preview": "Skip Preview",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"up_next": "Up next",
|
||||
"next_episode_in": "Next episode in {{seconds}}s",
|
||||
"play_now": "Play now",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"casting_player": {
|
||||
"buffering": "Buffering...",
|
||||
"changing_audio": "Changing audio...",
|
||||
"changing_subtitles": "Changing subtitles...",
|
||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
||||
"connecting": "Connecting to Chromecast...",
|
||||
"unknown_device": "Unknown Device",
|
||||
"ending_at": "Ending at {{time}}",
|
||||
"unknown": "Unknown",
|
||||
"connected": "Connected",
|
||||
"volume": "Volume",
|
||||
"muted": "Muted",
|
||||
"disconnect": "Disconnect",
|
||||
"stop_casting": "Stop Casting",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"chromecast": "Chromecast",
|
||||
"device_name": "Device Name",
|
||||
"playback_settings": "Playback Settings",
|
||||
"version": "Version",
|
||||
"stop": "Stop",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"none": "None",
|
||||
"playback_speed": "Playback Speed",
|
||||
"normal": "Normal",
|
||||
"episodes": "Episodes",
|
||||
"season": "Season {{number}}",
|
||||
"minutes_short": "min",
|
||||
"episode_label": "Episode {{number}}",
|
||||
"forced": "Forced",
|
||||
"device": "Device",
|
||||
"cancel": "Cancel",
|
||||
"connection_quality": {
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"fair": "Fair",
|
||||
"poor": "Poor",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"error_title": "Chromecast Error",
|
||||
"error_description": "Something went wrong with the cast session",
|
||||
"retry": "Try Again",
|
||||
"critical_error_title": "Multiple Errors Detected",
|
||||
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
|
||||
"track_changed": "Track changed successfully",
|
||||
"audio_track_changed": "Audio track changed",
|
||||
"subtitle_track_changed": "Subtitle track changed",
|
||||
"seeking": "Seeking...",
|
||||
"seeking_error": "Failed to seek",
|
||||
"load_failed": "Failed to load media",
|
||||
"load_retry": "Retrying media load..."
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
@@ -471,23 +365,6 @@
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"segment_skip_settings": "Segment Skip Settings",
|
||||
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_intro_description": "Action when intro segment is detected",
|
||||
"skip_outro": "Skip Outro/Credits",
|
||||
"skip_outro_description": "Action when outro/credits segment is detected",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_recap_description": "Action when recap segment is detected",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_commercial_description": "Action when commercial segment is detected",
|
||||
"skip_preview": "Skip Preview",
|
||||
"skip_preview_description": "Action when preview segment is detected",
|
||||
"segment_skip_none": "None",
|
||||
"segment_skip_ask": "Show Skip Button",
|
||||
"segment_skip_auto": "Auto Skip",
|
||||
"autoplay_countdown_seconds": "Player countdown (seconds)",
|
||||
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
@@ -804,6 +681,50 @@
|
||||
"custom_links": {
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
@@ -1082,6 +1003,28 @@
|
||||
"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": {
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Countdown state for Chromecast next-episode autoplay. The watcher
|
||||
* (`useCastAutoplay`) writes it; the casting-player overlay reads it.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export interface CastAutoplayState {
|
||||
/** The episode queued to play next. */
|
||||
nextEpisode: BaseItemDto;
|
||||
/** Seconds left before it loads. */
|
||||
secondsRemaining: number;
|
||||
}
|
||||
|
||||
/** Active cast autoplay countdown, or null when none is running. */
|
||||
export const castAutoplayAtom = atom<CastAutoplayState | null>(null);
|
||||
@@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo } from "react";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||
import { writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "../mmkv";
|
||||
|
||||
@@ -176,9 +175,6 @@ export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// Segment skip behavior options
|
||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
||||
|
||||
// TV Typography scale presets
|
||||
export enum TVTypographyScale {
|
||||
Small = "small",
|
||||
@@ -246,23 +242,10 @@ export type Settings = {
|
||||
jellyseerrServerUrl?: string;
|
||||
useKefinTweaks: boolean;
|
||||
hiddenLibraries?: string[];
|
||||
/** Chromecast profile selection mode. "auto" detects per device. */
|
||||
chromecastProfile: ChromecastProfileMode;
|
||||
/** Optional manual Chromecast video bitrate cap, in bits per second. */
|
||||
chromecastMaxBitrate?: number;
|
||||
enableH265ForChromecast: boolean;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Media segment skip preferences
|
||||
skipIntro: SegmentSkipMode;
|
||||
skipOutro: SegmentSkipMode;
|
||||
skipRecap: SegmentSkipMode;
|
||||
skipCommercial: SegmentSkipMode;
|
||||
skipPreview: SegmentSkipMode;
|
||||
/** Native player next-episode countdown, in seconds. */
|
||||
autoplayCountdownSeconds: number;
|
||||
/** Chromecast next-episode countdown, in seconds. */
|
||||
castAutoplayCountdownSeconds: number;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
@@ -362,19 +345,10 @@ export const defaultValues: Settings = {
|
||||
jellyseerrServerUrl: undefined,
|
||||
useKefinTweaks: false,
|
||||
hiddenLibraries: [],
|
||||
chromecastProfile: "auto",
|
||||
chromecastMaxBitrate: undefined,
|
||||
enableH265ForChromecast: false,
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
autoPlayNextEpisode: true,
|
||||
// Media segment skip defaults
|
||||
skipIntro: "ask",
|
||||
skipOutro: "ask",
|
||||
skipRecap: "ask",
|
||||
skipCommercial: "ask",
|
||||
skipPreview: "ask",
|
||||
autoplayCountdownSeconds: 15,
|
||||
castAutoplayCountdownSeconds: 30,
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildChromecastProfile } from "./buildProfile";
|
||||
import { CONSERVATIVE_CAPABILITIES } from "./capabilities";
|
||||
|
||||
describe("buildChromecastProfile", () => {
|
||||
test("conservative caps produce an H.264-only video codec list", () => {
|
||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
expect(videoCodecProfile?.Codec).toBe("h264");
|
||||
});
|
||||
|
||||
test("HEVC-capable caps include hevc in the video codec list", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
hevc: true,
|
||||
});
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
expect(videoCodecProfile?.Codec).toContain("hevc");
|
||||
});
|
||||
|
||||
test("maxVideoBitrate drives MaxStreamingBitrate", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
maxVideoBitrate: 5_000_000,
|
||||
});
|
||||
expect(profile.MaxStreamingBitrate).toBe(5_000_000);
|
||||
});
|
||||
|
||||
test("maxAudioChannels constrains transcoding profiles", () => {
|
||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
||||
const videoTranscode = profile.TranscodingProfiles?.find(
|
||||
(p) => p.Type === "Video",
|
||||
);
|
||||
expect(videoTranscode?.MaxAudioChannels).toBe("2");
|
||||
});
|
||||
|
||||
test("non-10bit HEVC caps add a video bit-depth condition", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
hevc: true,
|
||||
hevc10bit: false,
|
||||
});
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
const bitDepthCondition = videoCodecProfile?.Conditions?.find(
|
||||
(cond) => cond.Property === "VideoBitDepth",
|
||||
);
|
||||
expect(bitDepthCondition).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import type {
|
||||
DeviceProfile,
|
||||
ProfileCondition,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { ChromecastCapabilities } from "./capabilities";
|
||||
|
||||
/**
|
||||
* Build a Jellyfin `DeviceProfile` for a Chromecast from its detected capabilities.
|
||||
* Replaces the former static `chromecast.ts` / `chromecasth265.ts` profiles.
|
||||
*/
|
||||
export const buildChromecastProfile = (
|
||||
caps: ChromecastCapabilities,
|
||||
): DeviceProfile => {
|
||||
const videoCodecs = caps.hevc ? "hevc,h264" : "h264";
|
||||
const maxHeight = caps.maxResolution === 2160 ? "2160" : "1080";
|
||||
const maxChannels = String(caps.maxAudioChannels);
|
||||
|
||||
const videoConditions: ProfileCondition[] = [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "Height",
|
||||
Value: maxHeight,
|
||||
IsRequired: false,
|
||||
},
|
||||
];
|
||||
// When HEVC is allowed but 10-bit is not, force the server to transcode
|
||||
// 10-bit sources down to 8-bit.
|
||||
if (caps.hevc && !caps.hevc10bit) {
|
||||
videoConditions.push({
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoBitDepth",
|
||||
Value: "8",
|
||||
IsRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Name: "Chromecast Video Profile",
|
||||
MaxStreamingBitrate: caps.maxVideoBitrate,
|
||||
MaxStaticBitrate: caps.maxVideoBitrate,
|
||||
MusicStreamingTranscodingBitrate: 384000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: "Video",
|
||||
Codec: videoCodecs,
|
||||
Conditions: videoConditions,
|
||||
},
|
||||
{
|
||||
Type: "Audio",
|
||||
Codec: "aac,mp3,flac,opus,vorbis",
|
||||
// Force transcode of multichannel audio the receiver cannot output.
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "AudioChannels",
|
||||
Value: maxChannels,
|
||||
IsRequired: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Container: caps.hevc ? "mp4,mkv" : "mp4",
|
||||
Type: "Video",
|
||||
VideoCodec: videoCodecs,
|
||||
AudioCodec: "aac,mp3,opus,vorbis",
|
||||
},
|
||||
{ Container: "mp3", Type: "Audio" },
|
||||
{ Container: "aac", Type: "Audio" },
|
||||
{ Container: "flac", Type: "Audio" },
|
||||
{ Container: "wav", Type: "Audio" },
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Container: "ts",
|
||||
Type: "Video",
|
||||
VideoCodec: videoCodecs,
|
||||
AudioCodec: "aac,mp3",
|
||||
Protocol: "hls",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: maxChannels,
|
||||
MinSegments: 2,
|
||||
BreakOnNonKeyFrames: true,
|
||||
},
|
||||
{
|
||||
Container: "mp3",
|
||||
Type: "Audio",
|
||||
AudioCodec: "mp3",
|
||||
Protocol: "http",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: maxChannels,
|
||||
},
|
||||
{
|
||||
Container: "aac",
|
||||
Type: "Audio",
|
||||
AudioCodec: "aac",
|
||||
Protocol: "http",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: maxChannels,
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [{ Format: "vtt", Method: "Encode" }],
|
||||
};
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CONSERVATIVE_CAPABILITIES, detectCapabilities } from "./capabilities";
|
||||
|
||||
describe("detectCapabilities", () => {
|
||||
test("unknown device falls back to the conservative baseline", () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Some Unknown TV" },
|
||||
{ profileMode: "auto" },
|
||||
);
|
||||
expect(caps).toEqual(CONSERVATIVE_CAPABILITIES);
|
||||
});
|
||||
|
||||
test("null device falls back to the conservative baseline", () => {
|
||||
const caps = detectCapabilities(null, { profileMode: "auto" });
|
||||
expect(caps).toEqual(CONSERVATIVE_CAPABILITIES);
|
||||
});
|
||||
|
||||
test('plain "Chromecast" (gen 1/2/3) gets the conservative baseline', () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "auto" },
|
||||
);
|
||||
expect(caps.hevc).toBe(false);
|
||||
expect(caps.maxResolution).toBe(1080);
|
||||
expect(caps.maxAudioChannels).toBe(2);
|
||||
});
|
||||
|
||||
test("Chromecast Ultra is recognised with HEVC + 4K", () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast Ultra" },
|
||||
{ profileMode: "auto" },
|
||||
);
|
||||
expect(caps.hevc).toBe(true);
|
||||
expect(caps.maxResolution).toBe(2160);
|
||||
});
|
||||
|
||||
test('"force-h264" override disables HEVC even on a capable device', () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast Ultra" },
|
||||
{ profileMode: "force-h264" },
|
||||
);
|
||||
expect(caps.hevc).toBe(false);
|
||||
expect(caps.hevc10bit).toBe(false);
|
||||
});
|
||||
|
||||
test('"force-hevc" override enables HEVC on the conservative baseline', () => {
|
||||
const caps = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "force-hevc" },
|
||||
);
|
||||
expect(caps.hevc).toBe(true);
|
||||
});
|
||||
|
||||
test("maxBitrate override clamps but never raises the bitrate", () => {
|
||||
const lowered = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "auto", maxBitrate: 3_000_000 },
|
||||
);
|
||||
expect(lowered.maxVideoBitrate).toBe(3_000_000);
|
||||
|
||||
const raised = detectCapabilities(
|
||||
{ modelName: "Chromecast" },
|
||||
{ profileMode: "auto", maxBitrate: 999_000_000 },
|
||||
);
|
||||
expect(raised.maxVideoBitrate).toBe(
|
||||
CONSERVATIVE_CAPABILITIES.maxVideoBitrate,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Chromecast device capability detection.
|
||||
*
|
||||
* The Cast SDK exposes a device's `modelName` but no codec-level capability API.
|
||||
* We map known model names to a capability profile and fall back to a conservative
|
||||
* baseline (H.264 / 1080p / stereo) for anything unrecognised — a baseline that
|
||||
* cannot produce an unplayable stream on any Cast receiver.
|
||||
*/
|
||||
|
||||
/** Profile selection mode, surfaced as an advanced setting. */
|
||||
export type ChromecastProfileMode = "auto" | "force-hevc" | "force-h264";
|
||||
|
||||
export interface ChromecastCapabilities {
|
||||
/** HEVC 8-bit (Main profile) decode support. */
|
||||
hevc: boolean;
|
||||
/** HEVC 10-bit (Main10) decode support. */
|
||||
hevc10bit: boolean;
|
||||
/** Maximum video resolution height. */
|
||||
maxResolution: 1080 | 2160;
|
||||
/** Maximum video bitrate in bits per second. */
|
||||
maxVideoBitrate: number;
|
||||
/** Maximum audio channels the receiver can output. */
|
||||
maxAudioChannels: number;
|
||||
}
|
||||
|
||||
/** Minimal shape we need from the Cast SDK `Device` — keeps this module import-free. */
|
||||
interface DeviceLike {
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
/** Overrides derived from user settings. */
|
||||
export interface CapabilityOverrides {
|
||||
profileMode: ChromecastProfileMode;
|
||||
/** Optional manual cap in bits per second. */
|
||||
maxBitrate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline for a 1st/2nd/3rd-gen Chromecast and any unrecognised device.
|
||||
* `maxVideoBitrate` is an initial estimate — see docs/chromecast-test-matrix.md.
|
||||
*/
|
||||
export const CONSERVATIVE_CAPABILITIES: ChromecastCapabilities = {
|
||||
hevc: false,
|
||||
hevc10bit: false,
|
||||
maxResolution: 1080,
|
||||
maxVideoBitrate: 8_000_000,
|
||||
maxAudioChannels: 2,
|
||||
};
|
||||
|
||||
/** Known Cast devices keyed by `Device.modelName`. Unlisted models stay conservative. */
|
||||
const CHROMECAST_REGISTRY: Record<string, ChromecastCapabilities> = {
|
||||
"Chromecast Ultra": {
|
||||
hevc: true,
|
||||
hevc10bit: false,
|
||||
maxResolution: 2160,
|
||||
maxVideoBitrate: 20_000_000,
|
||||
maxAudioChannels: 6,
|
||||
},
|
||||
"Chromecast with Google TV": {
|
||||
hevc: true,
|
||||
hevc10bit: true,
|
||||
maxResolution: 2160,
|
||||
maxVideoBitrate: 20_000_000,
|
||||
maxAudioChannels: 6,
|
||||
},
|
||||
"Google TV Streamer": {
|
||||
hevc: true,
|
||||
hevc10bit: true,
|
||||
maxResolution: 2160,
|
||||
maxVideoBitrate: 25_000_000,
|
||||
maxAudioChannels: 8,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective capabilities for a Cast device.
|
||||
* Registry lookup → conservative fallback → user overrides applied last.
|
||||
*/
|
||||
export const detectCapabilities = (
|
||||
device: DeviceLike | null,
|
||||
overrides: CapabilityOverrides,
|
||||
): ChromecastCapabilities => {
|
||||
const base =
|
||||
(device?.modelName && CHROMECAST_REGISTRY[device.modelName]) ||
|
||||
CONSERVATIVE_CAPABILITIES;
|
||||
|
||||
const caps: ChromecastCapabilities = { ...base };
|
||||
|
||||
if (overrides.profileMode === "force-hevc") {
|
||||
caps.hevc = true;
|
||||
} else if (overrides.profileMode === "force-h264") {
|
||||
caps.hevc = false;
|
||||
caps.hevc10bit = false;
|
||||
}
|
||||
|
||||
if (overrides.maxBitrate && overrides.maxBitrate > 0) {
|
||||
caps.maxVideoBitrate = Math.min(caps.maxVideoBitrate, overrides.maxBitrate);
|
||||
}
|
||||
|
||||
return caps;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isLoadFailedError } from "./castErrors";
|
||||
|
||||
describe("isLoadFailedError", () => {
|
||||
test("recognises a status 2100 error message", () => {
|
||||
const error = new Error(
|
||||
"java.lang.Exception: Media control channel status code 2100",
|
||||
);
|
||||
expect(isLoadFailedError(error)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for unrelated errors", () => {
|
||||
expect(isLoadFailedError(new Error("network timeout"))).toBe(false);
|
||||
});
|
||||
|
||||
test("handles non-Error values without throwing", () => {
|
||||
expect(isLoadFailedError("status code 2100")).toBe(true);
|
||||
expect(isLoadFailedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Cast load error classification. Kept dependency-free so it is unit-testable
|
||||
* without pulling React Native modules into the test runtime.
|
||||
*/
|
||||
|
||||
/** True when an error is a Cast "LOAD_FAILED" (status 2100) rejection. */
|
||||
export const isLoadFailedError = (error: unknown): boolean => {
|
||||
if (error == null) return false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("2100");
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Unified Chromecast media loading.
|
||||
*
|
||||
* Owns the getStreamUrl → buildCastMediaInfo → loadMedia sequence that was
|
||||
* previously duplicated across PlayButton and the casting player. Builds the
|
||||
* device profile from detected capabilities and retries once with a forced
|
||||
* conservative profile when the receiver rejects the initial load (status 2100).
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import { buildChromecastProfile } from "@/utils/casting/buildProfile";
|
||||
import {
|
||||
type ChromecastProfileMode,
|
||||
detectCapabilities,
|
||||
} from "@/utils/casting/capabilities";
|
||||
import { isLoadFailedError } from "@/utils/casting/castErrors";
|
||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||
import { resolveSelection } from "@/utils/casting/selection";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
|
||||
export interface CastLoadOptions {
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
maxBitrate?: number;
|
||||
mediaSourceId?: string;
|
||||
startPositionMs?: number;
|
||||
}
|
||||
|
||||
export interface CastLoadParams {
|
||||
client: RemoteMediaClient;
|
||||
/** Cast device — only `modelName` is read, for capability detection. */
|
||||
device: { modelName?: string } | null;
|
||||
api: Api;
|
||||
item: BaseItemDto;
|
||||
userId: string;
|
||||
profileMode: ChromecastProfileMode;
|
||||
/** Manual bitrate cap from settings, in bits per second. */
|
||||
maxBitrateSetting?: number;
|
||||
options?: CastLoadOptions;
|
||||
}
|
||||
|
||||
export type CastLoadResult = { ok: true } | { ok: false; error: unknown };
|
||||
|
||||
const attemptLoad = async (
|
||||
params: CastLoadParams,
|
||||
caps: Parameters<typeof buildChromecastProfile>[0],
|
||||
): Promise<void> => {
|
||||
const { api, item, userId, client, options } = params;
|
||||
const profile = buildChromecastProfile(caps);
|
||||
|
||||
const selection = resolveSelection(item, {
|
||||
mediaSourceId: options?.mediaSourceId,
|
||||
audioStreamIndex: options?.audioStreamIndex,
|
||||
subtitleStreamIndex: options?.subtitleStreamIndex,
|
||||
maxBitrate: options?.maxBitrate,
|
||||
});
|
||||
|
||||
const startPositionMs = options?.startPositionMs ?? 0;
|
||||
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
startTimeTicks: Math.floor(startPositionMs * 10000),
|
||||
deviceProfile: profile,
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
maxStreamingBitrate: selection.maxBitrate,
|
||||
mediaSourceId: selection.mediaSourceId,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
throw new Error("getStreamUrl returned no URL");
|
||||
}
|
||||
|
||||
const playMethod: "Transcode" | "DirectPlay" = data.mediaSource
|
||||
?.TranscodingUrl
|
||||
? "Transcode"
|
||||
: "DirectPlay";
|
||||
|
||||
await client.loadMedia({
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
playSessionId: data.sessionId ?? undefined,
|
||||
selection,
|
||||
playMethod,
|
||||
}),
|
||||
startTime: startPositionMs / 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Load media onto the connected Chromecast.
|
||||
* On a status-2100 rejection, retries once with a forced conservative profile.
|
||||
*/
|
||||
export const loadCastMedia = async (
|
||||
params: CastLoadParams,
|
||||
): Promise<CastLoadResult> => {
|
||||
const caps = detectCapabilities(params.device, {
|
||||
profileMode: params.profileMode,
|
||||
maxBitrate: params.maxBitrateSetting,
|
||||
});
|
||||
|
||||
try {
|
||||
await attemptLoad(params, caps);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (!isLoadFailedError(error)) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
// Downgrade-on-failure: one retry with the safest possible profile.
|
||||
// The bitrate cap must also be applied to the explicit getStreamUrl
|
||||
// `maxBitrate` request param — Jellyfin uses that as the effective
|
||||
// ceiling, so the conservative profile alone would not lower it.
|
||||
try {
|
||||
const fallback = detectCapabilities(params.device, {
|
||||
profileMode: "force-h264",
|
||||
});
|
||||
const FALLBACK_MAX_BITRATE = 4_000_000;
|
||||
const fallbackParams: CastLoadParams = {
|
||||
...params,
|
||||
options: {
|
||||
...params.options,
|
||||
maxBitrate: Math.min(
|
||||
params.options?.maxBitrate ?? Number.POSITIVE_INFINITY,
|
||||
FALLBACK_MAX_BITRATE,
|
||||
),
|
||||
},
|
||||
};
|
||||
await attemptLoad(fallbackParams, {
|
||||
...fallback,
|
||||
maxVideoBitrate: FALLBACK_MAX_BITRATE,
|
||||
maxAudioChannels: 2,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (retryError) {
|
||||
return { ok: false, error: retryError };
|
||||
}
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user