mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-11 14:52:23 +00:00
Compare commits
39 Commits
fix/refres
...
refactor-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6422c1b03 | ||
|
|
3badf08363 | ||
|
|
5cbbd9dbb6 | ||
|
|
f34997a024 | ||
|
|
2c27186e22 | ||
|
|
7c81c0ff33 | ||
|
|
c243fbc0ba | ||
|
|
761b464fb6 | ||
|
|
f6a47b9867 | ||
|
|
bc08df903f | ||
|
|
4ad07d22bd | ||
|
|
da52b9c4b3 | ||
|
|
14d0f53c07 | ||
|
|
ef2cc19e21 | ||
|
|
338f42b980 | ||
|
|
9bf17dd96e | ||
|
|
b85fbc224b | ||
|
|
da1b089075 | ||
|
|
b353d7acea | ||
|
|
c6bf16afdd | ||
|
|
dc9750d7fc | ||
|
|
49c4f2d7ad | ||
|
|
519b2aa72f | ||
|
|
4a2d365d31 | ||
|
|
a65ac939cc | ||
|
|
0ce6266c02 | ||
|
|
34f7eea76d | ||
|
|
78a132268e | ||
|
|
aaca343327 | ||
|
|
25e20fe972 | ||
|
|
61d322146a | ||
|
|
2c1a2a9583 | ||
|
|
441ede0641 | ||
|
|
27e1dce1ca | ||
|
|
5f2d183459 | ||
|
|
1b66541e2f | ||
|
|
e98e075572 | ||
|
|
c234755134 | ||
|
|
86157c045c |
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 Jellyseerr APIs,
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
|
||||
## Coding Standards
|
||||
## Code Quality 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
|
||||
@@ -50,8 +71,10 @@ 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
|
||||
- Use React.memo() for performance optimization when needed
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Write self-documenting code with clear intent
|
||||
- Add comments only when code complexity requires explanation
|
||||
|
||||
## API Integration
|
||||
|
||||
@@ -85,6 +108,18 @@ 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)
|
||||
|
||||
238
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
238
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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",
|
||||
},
|
||||
];
|
||||
@@ -11,6 +11,7 @@ import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -118,6 +119,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<CastingMiniPlayer />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
|
||||
1446
app/(auth)/casting-player.tsx
Normal file
1446
app/(auth)/casting-player.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -939,7 +939,7 @@ export default function page() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
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 { 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({
|
||||
@@ -18,23 +26,137 @@ export function Chromecast({
|
||||
background = "transparent",
|
||||
...props
|
||||
}) {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
// Hooks called for their side effects (keep Chromecast session active)
|
||||
useRemoteMediaClient();
|
||||
useCastDevice();
|
||||
const castState = useCastState();
|
||||
useDevices();
|
||||
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(() => {
|
||||
(async () => {
|
||||
let isSubscribed = true;
|
||||
let retryTimeout: NodeJS.Timeout;
|
||||
|
||||
const startDiscoveryWithRetry = async () => {
|
||||
if (!discoveryManager) {
|
||||
console.warn("DiscoveryManager is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
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) {
|
||||
const randomBytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
// Format as UUID v4
|
||||
randomBytes[6] = (randomBytes[6] & 0x0f) | 0x40; // Version 4
|
||||
randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80; // Variant 10
|
||||
const uuid = Array.from(randomBytes, (b, i) => {
|
||||
const hex = b.toString(16).padStart(2, "0");
|
||||
return [4, 6, 8, 10].includes(i) ? `-${hex}` : hex;
|
||||
}).join("");
|
||||
playSessionIdRef.current = uuid;
|
||||
lastContentIdRef.current = contentId;
|
||||
}
|
||||
|
||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||
const isPaused = mediaStatus.playerState === "paused";
|
||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
||||
|
||||
const progressInfo: PlaybackProgressInfo = {
|
||||
ItemId: contentId,
|
||||
PositionTicks: positionTicks,
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: playSessionIdRef.current || contentId,
|
||||
};
|
||||
|
||||
getPlaystateApi(api)
|
||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||
.then(() => {
|
||||
lastReportedProgressRef.current = streamPosition;
|
||||
lastReportedPlayerStateRef.current = playerState;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to report Chromecast progress:", error);
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.mediaInfo?.contentUrl,
|
||||
]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
@@ -43,50 +165,92 @@ 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={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</Pressable>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundButton
|
||||
size='large'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ interface PlatformDropdownProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
disabled?: boolean;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
@@ -197,6 +198,7 @@ const PlatformDropdownComponent = ({
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
disabled,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
@@ -231,6 +233,13 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
if (disabled) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
@@ -353,8 +362,14 @@ const PlatformDropdownComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={disabled ? { opacity: 0.5 } : undefined}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaStreamType,
|
||||
MediaPlayerState,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
@@ -32,8 +32,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
@@ -111,7 +110,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const options = [
|
||||
t("casting_player.chromecast"),
|
||||
t("casting_player.device"),
|
||||
t("casting_player.cancel"),
|
||||
];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -120,9 +123,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
// 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 isOpeningCurrentlyPlayingMedia =
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
isMediaActive && currentContentId && currentContentId === item?.Id;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
@@ -175,8 +183,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
});
|
||||
|
||||
console.log("URL: ", data?.url, enableH265);
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
@@ -186,74 +192,16 @@ export const PlayButton: React.FC<Props> = ({
|
||||
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,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
}),
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
@@ -261,10 +209,13 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
router.push("/casting-player");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[PlayButton] loadMedia failed:", err);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.error("[PlayButton] Cast error:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
457
components/casting/CastingMiniPlayer.tsx
Normal file
457
components/casting/CastingMiniPlayer.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Unified Casting Mini Player
|
||||
* Works with all supported casting protocols
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, 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 { Text } from "@/components/common/Text";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
formatTime,
|
||||
formatTrickplayTime,
|
||||
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 insets = useSafeAreaInsets();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
|
||||
const currentItem = useMemo(() => {
|
||||
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
|
||||
}, [mediaStatus?.mediaInfo?.customData]);
|
||||
|
||||
// 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 [scrubPercentage, setScrubPercentage] = useState(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 });
|
||||
|
||||
// Track scrub percentage for bubble positioning
|
||||
if (duration > 0) {
|
||||
setScrubPercentage(value / duration);
|
||||
}
|
||||
}}
|
||||
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={() => {
|
||||
// Calculate bubble position with edge clamping
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const sliderPadding = 8;
|
||||
const thumbWidth = 10; // matches thumbWidth prop on Slider
|
||||
const sliderWidth = screenWidth - sliderPadding * 2;
|
||||
// Adjust thumb position to account for thumb width affecting travel range
|
||||
const effectiveTrackWidth = sliderWidth - thumbWidth;
|
||||
const thumbPosition =
|
||||
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
|
||||
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
// Show simple time bubble when no trickplay
|
||||
const timeBubbleWidth = 70;
|
||||
const minLeft = -thumbPosition;
|
||||
const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
|
||||
const centeredLeft = -timeBubbleWidth / 2;
|
||||
const clampedLeft = Math.max(
|
||||
minLeft,
|
||||
Math.min(maxLeft, centeredLeft),
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 12,
|
||||
left: clampedLeft,
|
||||
backgroundColor: protocolColor,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ color: "#fff", fontSize: 11, fontWeight: "600" }}
|
||||
>
|
||||
{formatTrickplayTime(trickplayTime)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileWidth = 140; // Smaller preview for mini player
|
||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
||||
|
||||
// Calculate clamped position for trickplay preview
|
||||
const minLeft = -thumbPosition;
|
||||
const maxLeft = sliderWidth - thumbPosition - tileWidth;
|
||||
const centeredLeft = -tileWidth / 2;
|
||||
const clampedLeft = Math.max(
|
||||
minLeft,
|
||||
Math.min(maxLeft, centeredLeft),
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 12,
|
||||
left: clampedLeft,
|
||||
width: tileWidth,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Trickplay image preview */}
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
||||
height:
|
||||
(tileWidth / (trickplayInfo.aspectRatio ?? 1.78)) *
|
||||
(trickplayInfo.data?.TileHeight ?? 1),
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
{/* Time overlay */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ color: "#fff", fontSize: 10, fontWeight: "600" }}
|
||||
>
|
||||
{formatTrickplayTime(trickplayTime)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
sliderHeight={3}
|
||||
thumbWidth={10}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
321
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
321
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
348
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
348
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
351
components/chromecast/ChromecastEpisodeList.tsx
Normal file
351
components/chromecast/ChromecastEpisodeList.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 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,
|
||||
backgroundColor: isCurrentEpisode ? "#a855f7" : "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>
|
||||
);
|
||||
};
|
||||
347
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
347
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Chromecast Settings Menu
|
||||
* Allows users to configure audio, subtitles, quality, and playback speed
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useState } from "react";
|
||||
import { 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,
|
||||
MediaSource,
|
||||
SubtitleTrack,
|
||||
} from "@/utils/casting/types";
|
||||
|
||||
interface ChromecastSettingsMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
item: BaseItemDto;
|
||||
mediaSources: MediaSource[];
|
||||
selectedMediaSource: MediaSource | null;
|
||||
onMediaSourceChange: (source: MediaSource) => void;
|
||||
audioTracks: AudioTrack[];
|
||||
selectedAudioTrack: AudioTrack | null;
|
||||
onAudioTrackChange: (track: AudioTrack) => void;
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
selectedSubtitleTrack: SubtitleTrack | null;
|
||||
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
|
||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
item: _item, // Reserved for future use (technical info display)
|
||||
mediaSources,
|
||||
selectedMediaSource,
|
||||
onMediaSourceChange,
|
||||
audioTracks,
|
||||
selectedAudioTrack,
|
||||
onAudioTrackChange,
|
||||
subtitleTracks,
|
||||
selectedSubtitleTrack,
|
||||
onSubtitleTrackChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "80%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.playback_settings")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
{/* Quality/Media Source - only show when sources available */}
|
||||
{mediaSources.length > 0 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.quality"),
|
||||
"film-outline",
|
||||
"quality",
|
||||
)}
|
||||
{mediaSources.length > 0 && expandedSection === "quality" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{mediaSources.map((source) => (
|
||||
<Pressable
|
||||
key={source.id}
|
||||
onPress={() => {
|
||||
onMediaSourceChange(source);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedMediaSource?.id === source.id
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{source.name}
|
||||
</Text>
|
||||
{source.bitrate && (
|
||||
<Text
|
||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
{Math.round(source.bitrate / 1000000)} Mbps
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedMediaSource?.id === source.id && (
|
||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio Tracks - only show if more than one track */}
|
||||
{audioTracks.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.audio"),
|
||||
"musical-notes",
|
||||
"audio",
|
||||
)}
|
||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{audioTracks.map((track) => (
|
||||
<Pressable
|
||||
key={track.index}
|
||||
onPress={() => {
|
||||
onAudioTrackChange(track);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedAudioTrack?.index === track.index
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown")}
|
||||
</Text>
|
||||
{track.codec && (
|
||||
<Text
|
||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
{track.codec.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedAudioTrack?.index === track.index && (
|
||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitle Tracks - only show if subtitles available */}
|
||||
{subtitleTracks.length > 0 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.subtitles"),
|
||||
"text",
|
||||
"subtitles",
|
||||
)}
|
||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSubtitleTrackChange(null);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedSubtitleTrack === null
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{t("casting_player.none")}
|
||||
</Text>
|
||||
{selectedSubtitleTrack === null && (
|
||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
||||
)}
|
||||
</Pressable>
|
||||
{subtitleTracks.map((track) => (
|
||||
<Pressable
|
||||
key={track.index}
|
||||
onPress={() => {
|
||||
onSubtitleTrackChange(track);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedSubtitleTrack?.index === track.index
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown")}
|
||||
</Text>
|
||||
{(track.codec || track.isForced) && (
|
||||
<Text
|
||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
{track.codec ? track.codec.toUpperCase() : ""}
|
||||
{track.isForced && ` • ${t("casting_player.forced")}`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedSubtitleTrack?.index === track.index && (
|
||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Playback Speed */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.playback_speed"),
|
||||
"speedometer",
|
||||
"speed",
|
||||
)}
|
||||
{expandedSection === "speed" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{PLAYBACK_SPEEDS.map((speed) => (
|
||||
<Pressable
|
||||
key={speed}
|
||||
onPress={() => {
|
||||
onPlaybackSpeedChange(speed);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
Math.abs(playbackSpeed - speed) < 0.01
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
|
||||
</Text>
|
||||
{Math.abs(playbackSpeed - speed) < 0.01 && (
|
||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
171
components/chromecast/hooks/useChromecastSegments.ts
Normal file
171
components/chromecast/hooks/useChromecastSegments.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
};
|
||||
@@ -47,7 +47,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
|
||||
return (
|
||||
<MoreMoviesWithActor
|
||||
key={person.Id}
|
||||
key={`${person.Id}-${idx}`}
|
||||
currentItem={item}
|
||||
actorId={person.Id}
|
||||
actorName={person.Name}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -15,6 +16,7 @@ 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();
|
||||
|
||||
@@ -248,6 +250,15 @@ 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>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,9 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
skipButtonText: string;
|
||||
showSkipCreditButton: boolean;
|
||||
skipCreditButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
@@ -67,7 +69,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
skipButtonText,
|
||||
showSkipCreditButton,
|
||||
skipCreditButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
@@ -136,7 +140,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
buttonText={skipButtonText}
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -146,7 +150,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
buttonText={skipCreditButtonText}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -16,17 +24,17 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -42,6 +50,9 @@ 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;
|
||||
@@ -110,6 +121,24 @@ export const Controls: FC<Props> = ({
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
|
||||
const playingRef = useRef(isPlaying);
|
||||
useEffect(() => {
|
||||
playingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -300,27 +329,125 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
maxMs,
|
||||
);
|
||||
// Convert milliseconds to seconds for segment comparison
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
|
||||
// Wrapper to convert segment skip from seconds to milliseconds
|
||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
// Cancel any pending play call to avoid race conditions
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
// Read latest isPlaying from ref to avoid stale closure
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
if (playingRef.current) {
|
||||
play();
|
||||
}
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments || [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments || [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments || [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments || [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Determine which segment button to show (priority order)
|
||||
// Commercial > Recap > Intro > Preview > Outro
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return { type: "Commercial", ...commercialSkipper };
|
||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
||||
if (previewSkipper.currentSegment)
|
||||
return { type: "Preview", ...previewSkipper };
|
||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
recapSkipper.currentSegment,
|
||||
introSkipper.currentSegment,
|
||||
previewSkipper.currentSegment,
|
||||
outroSkipper.currentSegment,
|
||||
commercialSkipper,
|
||||
recapSkipper,
|
||||
introSkipper,
|
||||
previewSkipper,
|
||||
outroSkipper,
|
||||
]);
|
||||
|
||||
// Legacy compatibility: map to old variable names
|
||||
const showSkipButton = !!(
|
||||
activeSegment &&
|
||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
||||
);
|
||||
const skipIntro = activeSegment?.skipSegment || noop;
|
||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||
const skipCredit = outroSkipper.skipSegment || noop;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
|
||||
// Get button text based on segment type using i18n
|
||||
const { t } = useTranslation();
|
||||
const skipButtonText = activeSegment
|
||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||
: t("player.skip_intro");
|
||||
const skipCreditButtonText = t("player.skip_outro");
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -534,7 +661,9 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
skipButtonText={skipButtonText}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipCreditButtonText={skipCreditButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
|
||||
@@ -120,13 +120,7 @@ const formatTranscodeReason = (reason: string): string => {
|
||||
};
|
||||
|
||||
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
({
|
||||
showControls,
|
||||
visible,
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
}) => {
|
||||
({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
393
hooks/useCasting.ts
Normal file
393
hooks/useCasting.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 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:
|
||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
||||
IsMuted: currentState.volume === 0,
|
||||
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
||||
},
|
||||
})
|
||||
.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:
|
||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
||||
VolumeLevel: Math.floor(s.volume * 100),
|
||||
IsMuted: s.volume === 0,
|
||||
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
||||
},
|
||||
})
|
||||
.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,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
]);
|
||||
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping credits in a media player.
|
||||
* The player reports time values in milliseconds.
|
||||
*/
|
||||
export const useCreditSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
totalDuration?: number,
|
||||
) => {
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
|
||||
const totalDurationInSeconds =
|
||||
totalDuration != null ? msToSeconds(totalDuration) : undefined;
|
||||
|
||||
// Regular function (not useCallback) to match useIntroSkipper pattern
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
itemId,
|
||||
isOffline,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
const creditTimestamps = segments?.creditSegments?.[0];
|
||||
|
||||
// Determine if there's content after credits (credits don't extend to video end)
|
||||
// Use a 5-second buffer to account for timing discrepancies
|
||||
const hasContentAfterCredits = (() => {
|
||||
if (
|
||||
!creditTimestamps ||
|
||||
totalDurationInSeconds == null ||
|
||||
!Number.isFinite(totalDurationInSeconds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const creditsEndToVideoEnd =
|
||||
totalDurationInSeconds - creditTimestamps.endTime;
|
||||
// If credits end more than 5 seconds before video ends, there's content after
|
||||
return creditsEndToVideoEnd > 5;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
const shouldShow =
|
||||
currentTimeSeconds > creditTimestamps.startTime &&
|
||||
currentTimeSeconds < creditTimestamps.endTime;
|
||||
|
||||
setShowSkipCreditButton(shouldShow);
|
||||
} else {
|
||||
// Reset button state when no credit timestamps exist
|
||||
if (showSkipCreditButton) {
|
||||
setShowSkipCreditButton(false);
|
||||
}
|
||||
}
|
||||
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
if (!creditTimestamps) return;
|
||||
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
|
||||
// Calculate the target seek position
|
||||
let seekTarget = creditTimestamps.endTime;
|
||||
|
||||
// If we have total duration, ensure we don't seek past the end of the video.
|
||||
// Some media sources report credit end times that exceed the actual video duration,
|
||||
// which causes the player to pause/stop when seeking past the end.
|
||||
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
|
||||
// (next episode countdown, etc.) instead of an abrupt pause.
|
||||
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
|
||||
seekTarget = Math.max(0, totalDurationInSeconds - 2);
|
||||
}
|
||||
|
||||
wrappedSeek(seekTarget);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
|
||||
}
|
||||
}, [
|
||||
creditTimestamps,
|
||||
lightHapticFeedback,
|
||||
wrappedSeek,
|
||||
play,
|
||||
totalDurationInSeconds,
|
||||
]);
|
||||
|
||||
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
* MPV player uses milliseconds for time.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in milliseconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
) => {
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
itemId,
|
||||
isOffline,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
const introTimestamps = segments?.introSegments?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
const shouldShow =
|
||||
currentTimeSeconds > introTimestamps.startTime &&
|
||||
currentTimeSeconds < introTimestamps.endTime;
|
||||
|
||||
setShowSkipButton(shouldShow);
|
||||
} else {
|
||||
if (showSkipButton) {
|
||||
setShowSkipButton(false);
|
||||
}
|
||||
}
|
||||
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(introTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error("[INTRO_SKIPPER] Error skipping intro", error);
|
||||
}
|
||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||
|
||||
return { showSkipButton, skipIntro };
|
||||
};
|
||||
113
hooks/useSegmentSkipper.ts
Normal file
113
hooks/useSegmentSkipper.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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,20 +17,24 @@ interface TrickplayUrl {
|
||||
}
|
||||
|
||||
/** Hook to handle trickplay logic for a given item. */
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
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(() => getTrickplayInfo(item), [item]);
|
||||
const trickplayInfo = useMemo(
|
||||
() => (item ? getTrickplayInfo(item) : null),
|
||||
[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`;
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export const useTrickplay = (item: BaseItemDto) => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!trickplayInfo ||
|
||||
!item.Id ||
|
||||
!item?.Id ||
|
||||
now - lastCalculationTime.current < throttleDelay
|
||||
)
|
||||
return;
|
||||
@@ -62,7 +66,7 @@ export const useTrickplay = (item: BaseItemDto) => {
|
||||
|
||||
/** 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[] = [];
|
||||
|
||||
@@ -32,12 +32,6 @@ 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. */
|
||||
@@ -56,6 +50,12 @@ 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,6 +144,12 @@ 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 */
|
||||
|
||||
@@ -24,6 +24,82 @@
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"player": {
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_outro": "Skip Outro",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_preview": "Skip Preview",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_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"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
@@ -308,6 +384,21 @@
|
||||
"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",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
@@ -590,26 +681,6 @@
|
||||
"custom_links": {
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
"no_items_to_display": "No Items to Display",
|
||||
|
||||
@@ -134,6 +134,9 @@ export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// Segment skip behavior options
|
||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
||||
|
||||
// Audio transcoding mode - controls how surround audio is handled
|
||||
// This controls server-side transcoding behavior for audio streams.
|
||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||
@@ -181,6 +184,12 @@ export type Settings = {
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Media segment skip preferences
|
||||
skipIntro: SegmentSkipMode;
|
||||
skipOutro: SegmentSkipMode;
|
||||
skipRecap: SegmentSkipMode;
|
||||
skipCommercial: SegmentSkipMode;
|
||||
skipPreview: SegmentSkipMode;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
@@ -266,6 +275,12 @@ export const defaultValues: Settings = {
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
autoPlayNextEpisode: true,
|
||||
// Media segment skip defaults
|
||||
skipIntro: "ask",
|
||||
skipOutro: "ask",
|
||||
skipRecap: "ask",
|
||||
skipCommercial: "ask",
|
||||
skipPreview: "ask",
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
|
||||
102
utils/casting/helpers.ts
Normal file
102
utils/casting/helpers.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Unified Casting Helper Functions
|
||||
* Common utilities for casting protocols
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format milliseconds to HH:MM:SS or MM:SS
|
||||
*/
|
||||
export const formatTime = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate ending time based on current progress and duration.
|
||||
* Uses locale-aware formatting when available.
|
||||
*/
|
||||
export const calculateEndingTime = (
|
||||
currentMs: number,
|
||||
durationMs: number,
|
||||
): string => {
|
||||
const remainingMs = durationMs - currentMs;
|
||||
const endTime = new Date(Date.now() + remainingMs);
|
||||
|
||||
try {
|
||||
return endTime.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
// Fallback for environments without Intl support
|
||||
const hours = endTime.getHours();
|
||||
const minutes = endTime.getMinutes();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get poster URL for item with specified dimensions
|
||||
*/
|
||||
export const getPosterUrl = (
|
||||
baseUrl: string | undefined,
|
||||
itemId: string | undefined,
|
||||
tag: string | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
): string | null => {
|
||||
if (!baseUrl || !itemId) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
maxWidth: width.toString(),
|
||||
maxHeight: height.toString(),
|
||||
quality: "90",
|
||||
...(tag && { tag }),
|
||||
});
|
||||
|
||||
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncate title to max length with ellipsis
|
||||
*/
|
||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
||||
if (maxLength < 4) return title.substring(0, maxLength);
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.substring(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current time is within a segment
|
||||
*/
|
||||
export const isWithinSegment = (
|
||||
currentMs: number,
|
||||
segment: { start: number; end: number } | null,
|
||||
): boolean => {
|
||||
if (!segment) return false;
|
||||
const currentSeconds = currentMs / 1000;
|
||||
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format trickplay time from {hours, minutes, seconds} to display string.
|
||||
* Produces "H:MM:SS" when hours > 0, otherwise "MM:SS".
|
||||
*/
|
||||
export const formatTrickplayTime = (time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}): string => {
|
||||
const mm = String(time.minutes).padStart(2, "0");
|
||||
const ss = String(time.seconds).padStart(2, "0");
|
||||
return time.hours > 0 ? `${time.hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
};
|
||||
120
utils/casting/mediaInfo.ts
Normal file
120
utils/casting/mediaInfo.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Shared helper to build Chromecast media metadata.
|
||||
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { MediaStreamType } from "react-native-google-cast";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
/**
|
||||
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
|
||||
*
|
||||
* NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH
|
||||
* from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml"
|
||||
* actually BREAKS playback on many receivers. Always use "video/mp4" unless
|
||||
* you have a custom receiver that explicitly handles other MIME types.
|
||||
*/
|
||||
export const buildCastMediaInfo = ({
|
||||
item,
|
||||
streamUrl,
|
||||
api,
|
||||
contentType,
|
||||
isLive = false,
|
||||
}: {
|
||||
item: BaseItemDto;
|
||||
streamUrl: string;
|
||||
api: Api;
|
||||
/** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */
|
||||
contentType?: string;
|
||||
/** Set true for live TV streams to use MediaStreamType.LIVE. */
|
||||
isLive?: boolean;
|
||||
}) => {
|
||||
if (!item.Id) {
|
||||
throw new Error("Missing item.Id for media load — cannot build contentId");
|
||||
}
|
||||
|
||||
const itemId: string = item.Id;
|
||||
const streamDuration = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
const buildImages = (urls: (string | null | undefined)[]) =>
|
||||
urls.filter(Boolean).map((url) => ({ url: url as string }));
|
||||
|
||||
const buildItemMetadata = () => {
|
||||
if (item.Type === "Episode") {
|
||||
return {
|
||||
type: "tvShow" as const,
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: buildImages([
|
||||
getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") {
|
||||
return {
|
||||
type: "movie" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "generic" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
const metadata = buildItemMetadata();
|
||||
|
||||
// Build a slim customData payload with only the fields the casting-player needs.
|
||||
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
|
||||
// especially for movies with many chapters, media sources, and people.
|
||||
const slimCustomData: Partial<BaseItemDto> = {
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
SeriesName: item.SeriesName,
|
||||
SeriesId: item.SeriesId,
|
||||
SeasonId: item.SeasonId,
|
||||
IndexNumber: item.IndexNumber,
|
||||
ParentIndexNumber: item.ParentIndexNumber,
|
||||
ImageTags: item.ImageTags,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
Overview: item.Overview,
|
||||
MediaStreams: item.MediaStreams,
|
||||
MediaSources: item.MediaSources?.map((src) => ({
|
||||
Id: src.Id,
|
||||
Bitrate: src.Bitrate,
|
||||
Container: src.Container,
|
||||
Name: src.Name,
|
||||
})),
|
||||
UserData: item.UserData
|
||||
? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
contentId: itemId,
|
||||
contentUrl: streamUrl,
|
||||
contentType: contentType || "video/mp4",
|
||||
streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED,
|
||||
streamDuration,
|
||||
customData: slimCustomData,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
72
utils/casting/types.ts
Normal file
72
utils/casting/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Unified Casting Types and Options
|
||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
||||
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
export type CastProtocol = "chromecast";
|
||||
|
||||
export interface CastDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: CastProtocol;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface CastPlayerState {
|
||||
isConnected: boolean;
|
||||
isPlaying: boolean;
|
||||
currentItem: BaseItemDto | null;
|
||||
currentDevice: CastDevice | null;
|
||||
protocol: CastProtocol | null;
|
||||
progress: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isBuffering: boolean;
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
index: number;
|
||||
language: string;
|
||||
codec: string;
|
||||
displayTitle: string;
|
||||
}
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
language: string;
|
||||
codec: string;
|
||||
displayTitle: string;
|
||||
isForced: boolean;
|
||||
}
|
||||
|
||||
export interface MediaSource {
|
||||
id: string;
|
||||
name: string;
|
||||
bitrate?: number;
|
||||
container: string;
|
||||
}
|
||||
|
||||
export const CASTING_CONSTANTS = {
|
||||
POSTER_WIDTH: 300,
|
||||
POSTER_HEIGHT: 450,
|
||||
ANIMATION_DURATION: 300,
|
||||
CONTROL_HIDE_DELAY: 5000,
|
||||
PROGRESS_UPDATE_INTERVAL: 1000,
|
||||
SEEK_FORWARD_SECONDS: 10,
|
||||
SEEK_BACKWARD_SECONDS: 10,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_CAST_STATE: CastPlayerState = {
|
||||
isConnected: false,
|
||||
isPlaying: false,
|
||||
currentItem: null,
|
||||
currentDevice: null,
|
||||
protocol: null,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
volume: 0.5,
|
||||
isBuffering: false,
|
||||
};
|
||||
11
utils/chromecast/options.ts
Normal file
11
utils/chromecast/options.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Chromecast player configuration and types
|
||||
*/
|
||||
|
||||
export interface ChromecastSegmentData {
|
||||
intro: { start: number; end: number } | null;
|
||||
credits: { start: number; end: number } | null;
|
||||
recap: { start: number; end: number } | null;
|
||||
commercial: { start: number; end: number }[];
|
||||
preview: { start: number; end: number }[];
|
||||
}
|
||||
@@ -13,6 +13,14 @@ export const chromecast: DeviceProfile = {
|
||||
{
|
||||
Type: "Audio",
|
||||
Codec: "aac,mp3,flac,opus,vorbis",
|
||||
// Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "AudioChannels",
|
||||
Value: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
@@ -84,9 +92,5 @@ export const chromecast: DeviceProfile = {
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
{
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,7 +12,14 @@ export const chromecasth265: DeviceProfile = {
|
||||
},
|
||||
{
|
||||
Type: "Audio",
|
||||
Codec: "aac,mp3,flac,opus,vorbis",
|
||||
Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "AudioChannels",
|
||||
Value: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
@@ -84,9 +91,5 @@ export const chromecasth265: DeviceProfile = {
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
{
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -74,10 +74,16 @@ export const getSegmentsForItem = (
|
||||
): {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
} => {
|
||||
return {
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
recapSegments: item.recapSegments || [],
|
||||
commercialSegments: item.commercialSegments || [],
|
||||
previewSegments: item.previewSegments || [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -95,6 +101,9 @@ const fetchMediaSegments = async (
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
} | null> => {
|
||||
try {
|
||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||
@@ -102,13 +111,22 @@ const fetchMediaSegments = async (
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
params: {
|
||||
includeSegmentTypes: ["Intro", "Outro"],
|
||||
includeSegmentTypes: [
|
||||
"Intro",
|
||||
"Outro",
|
||||
"Recap",
|
||||
"Commercial",
|
||||
"Preview",
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
const recapSegments: MediaTimeSegment[] = [];
|
||||
const commercialSegments: MediaTimeSegment[] = [];
|
||||
const previewSegments: MediaTimeSegment[] = [];
|
||||
|
||||
response.data.Items.forEach((segment) => {
|
||||
const timeSegment: MediaTimeSegment = {
|
||||
@@ -124,13 +142,27 @@ const fetchMediaSegments = async (
|
||||
case "Outro":
|
||||
creditSegments.push(timeSegment);
|
||||
break;
|
||||
// Optionally handle other types like Recap, Commercial, Preview
|
||||
case "Recap":
|
||||
recapSegments.push(timeSegment);
|
||||
break;
|
||||
case "Commercial":
|
||||
commercialSegments.push(timeSegment);
|
||||
break;
|
||||
case "Preview":
|
||||
previewSegments.push(timeSegment);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
return {
|
||||
introSegments,
|
||||
creditSegments,
|
||||
recapSegments,
|
||||
commercialSegments,
|
||||
previewSegments,
|
||||
};
|
||||
} catch (_error) {
|
||||
// Return null to indicate we should try legacy endpoints
|
||||
return null;
|
||||
@@ -146,45 +178,47 @@ const fetchLegacySegments = async (
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
|
||||
try {
|
||||
const [introRes, creditRes] = await Promise.allSettled([
|
||||
api.axiosInstance.get<IntroTimestamps>(
|
||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||
{ headers: getAuthHeaders(api) },
|
||||
),
|
||||
api.axiosInstance.get<CreditTimestamps>(
|
||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||
{ headers: getAuthHeaders(api) },
|
||||
),
|
||||
]);
|
||||
const [introRes, creditRes] = await Promise.allSettled([
|
||||
api.axiosInstance.get<IntroTimestamps>(
|
||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||
{ headers: getAuthHeaders(api) },
|
||||
),
|
||||
api.axiosInstance.get<CreditTimestamps>(
|
||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||
{ headers: getAuthHeaders(api) },
|
||||
),
|
||||
]);
|
||||
|
||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||
introSegments.push({
|
||||
startTime: introRes.value.data.IntroStart,
|
||||
endTime: introRes.value.data.IntroEnd,
|
||||
text: "Intro",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
creditRes.status === "fulfilled" &&
|
||||
creditRes.value.data.Credits.Valid
|
||||
) {
|
||||
creditSegments.push({
|
||||
startTime: creditRes.value.data.Credits.Start,
|
||||
endTime: creditRes.value.data.Credits.End,
|
||||
text: "Credits",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch legacy segments", error);
|
||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||
introSegments.push({
|
||||
startTime: introRes.value.data.IntroStart,
|
||||
endTime: introRes.value.data.IntroEnd,
|
||||
text: "Intro",
|
||||
});
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
|
||||
creditSegments.push({
|
||||
startTime: creditRes.value.data.Credits.Start,
|
||||
endTime: creditRes.value.data.Credits.End,
|
||||
text: "Credits",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
introSegments,
|
||||
creditSegments,
|
||||
recapSegments: [],
|
||||
commercialSegments: [],
|
||||
previewSegments: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAndParseSegments = async (
|
||||
@@ -193,6 +227,9 @@ export const fetchAndParseSegments = async (
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
// Try new API first (Jellyfin 10.11+)
|
||||
const newSegments = await fetchMediaSegments(itemId, api);
|
||||
|
||||
Reference in New Issue
Block a user