mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-25 00:06:39 +01:00
Implements trickplay functionality with preview images to improve the casting player's seeking experience. Adds a progress slider with trickplay preview, allowing users to scrub through media with visual feedback. Integrates device volume control and mute functionality to the Chromecast device sheet. Also fixes minor bugs and improves UI.
1533 lines
53 KiB
TypeScript
1533 lines
53 KiB
TypeScript
/**
|
|
* Unified Casting Player Modal
|
|
* Protocol-agnostic full-screen player for all supported casting protocols
|
|
*/
|
|
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { Image } from "expo-image";
|
|
import { router, Stack } from "expo-router";
|
|
import { useAtomValue } from "jotai";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ActivityIndicator,
|
|
Dimensions,
|
|
Pressable,
|
|
ScrollView,
|
|
View,
|
|
} from "react-native";
|
|
import { Slider } from "react-native-awesome-slider";
|
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import GoogleCast, {
|
|
CastState,
|
|
MediaPlayerState,
|
|
MediaStreamType,
|
|
useCastDevice,
|
|
useCastState,
|
|
useMediaStatus,
|
|
useRemoteMediaClient,
|
|
} from "react-native-google-cast";
|
|
import Animated, {
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withSpring,
|
|
} from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
|
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
|
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
|
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
|
import { Text } from "@/components/common/Text";
|
|
import { useCasting } from "@/hooks/useCasting";
|
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import {
|
|
calculateEndingTime,
|
|
formatTime,
|
|
getPosterUrl,
|
|
shouldShowNextEpisodeCountdown,
|
|
truncateTitle,
|
|
} from "@/utils/casting/helpers";
|
|
import type { CastProtocol } from "@/utils/casting/types";
|
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
import { chromecast } from "@/utils/profiles/chromecast";
|
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
|
|
export default function CastingPlayerScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const api = useAtomValue(apiAtom);
|
|
const user = useAtomValue(userAtom);
|
|
const { settings } = useSettings();
|
|
const { t } = useTranslation();
|
|
|
|
// Get raw Chromecast state directly - same as old implementation
|
|
const castState = useCastState();
|
|
const mediaStatus = useMediaStatus();
|
|
const castDevice = useCastDevice();
|
|
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
|
useRemoteMediaClient();
|
|
|
|
// Shared values for progress slider (must be initialized before any early returns)
|
|
const sliderProgress = useSharedValue(0);
|
|
const sliderMin = useSharedValue(0);
|
|
const sliderMax = useSharedValue(100);
|
|
const isScrubbing = useRef(false);
|
|
|
|
// Trickplay time display
|
|
const [trickplayTime, setTrickplayTime] = useState({
|
|
hours: 0,
|
|
minutes: 0,
|
|
seconds: 0,
|
|
});
|
|
|
|
// Track scrub percentage for trickplay bubble positioning
|
|
const [scrubPercentage, setScrubPercentage] = useState(0);
|
|
|
|
// Live progress tracking - update every second
|
|
const [liveProgress, setLiveProgress] = useState(0);
|
|
|
|
// Fetch full item data from Jellyfin by ID
|
|
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
|
const [_isFetchingItem, setIsFetchingItem] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fetchItemData = async () => {
|
|
const itemId = mediaStatus?.mediaInfo?.contentId;
|
|
if (!itemId || !api || !user?.Id) return;
|
|
|
|
setIsFetchingItem(true);
|
|
try {
|
|
const res = await getUserLibraryApi(api).getItem({
|
|
itemId,
|
|
userId: user.Id,
|
|
});
|
|
console.log("[Casting Player] Fetched full item from API:", {
|
|
Type: res.data.Type,
|
|
Name: res.data.Name,
|
|
SeriesName: res.data.SeriesName,
|
|
SeasonId: res.data.SeasonId,
|
|
ParentIndexNumber: res.data.ParentIndexNumber,
|
|
IndexNumber: res.data.IndexNumber,
|
|
});
|
|
setFetchedItem(res.data);
|
|
} catch (error) {
|
|
console.error("[Casting Player] Failed to fetch item:", error);
|
|
} finally {
|
|
setIsFetchingItem(false);
|
|
}
|
|
};
|
|
|
|
fetchItemData();
|
|
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
|
|
|
useEffect(() => {
|
|
// Initialize with actual position
|
|
if (mediaStatus?.streamPosition) {
|
|
setLiveProgress(mediaStatus.streamPosition);
|
|
}
|
|
|
|
// Update every second when playing
|
|
const interval = setInterval(() => {
|
|
if (
|
|
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
|
mediaStatus?.streamPosition !== undefined
|
|
) {
|
|
setLiveProgress((prev) => prev + 1);
|
|
} else if (mediaStatus?.streamPosition !== undefined) {
|
|
// Sync with actual position when paused/buffering
|
|
setLiveProgress(mediaStatus.streamPosition);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
|
|
|
// Extract item from customData, or use fetched item, or create a minimal fallback
|
|
const currentItem = useMemo(() => {
|
|
// Priority 1: Use fetched item from API (most reliable)
|
|
if (fetchedItem) {
|
|
console.log("[Casting Player] Using fetched item from API");
|
|
return fetchedItem;
|
|
}
|
|
|
|
// Priority 2: Try customData from mediaStatus
|
|
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
|
if (customData?.Type && customData.Type !== "Movie") {
|
|
// Only use customData if it has a real Type (not default fallback)
|
|
console.log("[Casting Player] Using customData item:", {
|
|
Type: customData.Type,
|
|
Name: customData.Name,
|
|
});
|
|
return customData;
|
|
}
|
|
|
|
// Priority 3: Create minimal fallback while loading
|
|
if (mediaStatus?.mediaInfo) {
|
|
const { contentId, metadata } = mediaStatus.mediaInfo;
|
|
console.log(
|
|
"[Casting Player] Using minimal fallback item (still loading)",
|
|
);
|
|
return {
|
|
Id: contentId,
|
|
Name: metadata?.title || "Unknown",
|
|
Type: "Movie", // Temporary until API fetch completes
|
|
ServerId: "",
|
|
} as BaseItemDto;
|
|
}
|
|
|
|
return null;
|
|
}, [fetchedItem, mediaStatus?.mediaInfo]);
|
|
|
|
// Derive state from raw Chromecast hooks
|
|
const protocol: CastProtocol = "chromecast";
|
|
const progress = liveProgress; // Use live-updating progress
|
|
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
|
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
|
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
|
const currentDevice = castDevice?.friendlyName ?? null;
|
|
|
|
// Trickplay for seeking preview - use fetched item with full data
|
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
fetchedItem ?? ({} as BaseItemDto),
|
|
);
|
|
|
|
// Update slider max when duration changes
|
|
useEffect(() => {
|
|
if (duration > 0) {
|
|
sliderMax.value = duration * 1000; // Convert to milliseconds
|
|
}
|
|
}, [duration, sliderMax]);
|
|
|
|
// Update slider progress when not scrubbing
|
|
useEffect(() => {
|
|
if (!isScrubbing.current && progress > 0) {
|
|
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
|
}
|
|
}, [progress, sliderProgress]);
|
|
|
|
// Only use casting controls if we have a current item to avoid "No session" errors
|
|
const castingControls = useCasting(currentItem);
|
|
const {
|
|
togglePlayPause,
|
|
skipForward,
|
|
skipBackward,
|
|
setVolume,
|
|
volume,
|
|
remoteMediaClient,
|
|
} = currentItem
|
|
? castingControls
|
|
: {
|
|
togglePlayPause: async () => {},
|
|
skipForward: async () => {},
|
|
skipBackward: async () => {},
|
|
setVolume: async () => {},
|
|
volume: 1,
|
|
remoteMediaClient: null,
|
|
};
|
|
|
|
// Modal states
|
|
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
|
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
|
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
|
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
|
|
|
// Track selection states
|
|
const [selectedAudioTrackIndex, setSelectedAudioTrackIndex] = useState<
|
|
number | null
|
|
>(null);
|
|
const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState<
|
|
number | null
|
|
>(null);
|
|
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
|
|
|
// Function to reload media with new audio/subtitle/quality settings
|
|
const reloadWithSettings = useCallback(
|
|
async (options: {
|
|
audioIndex?: number;
|
|
subtitleIndex?: number | null;
|
|
bitrateValue?: number;
|
|
}) => {
|
|
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
|
console.warn("[Casting Player] Cannot reload - missing required data");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Save current playback position
|
|
const currentPosition = mediaStatus?.streamPosition ?? 0;
|
|
console.log(
|
|
"[Casting Player] Reloading stream at position:",
|
|
currentPosition,
|
|
);
|
|
|
|
// Get new stream URL with updated settings
|
|
const enableH265 = settings.enableH265ForChromecast;
|
|
const data = await getStreamUrl({
|
|
api,
|
|
item: currentItem,
|
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
|
startTimeTicks: Math.floor(currentPosition * 10000000), // Convert seconds to ticks
|
|
userId: user.Id,
|
|
audioStreamIndex:
|
|
options.audioIndex ?? selectedAudioTrackIndex ?? undefined,
|
|
subtitleStreamIndex: options.subtitleIndex ?? undefined,
|
|
maxStreamingBitrate: options.bitrateValue,
|
|
});
|
|
|
|
if (!data?.url) {
|
|
console.error("[Casting Player] Failed to get stream URL");
|
|
return;
|
|
}
|
|
|
|
console.log("[Casting Player] Reloading with new URL:", data.url);
|
|
|
|
// Reload media with new URL
|
|
await remoteMediaClient.loadMedia({
|
|
mediaInfo: {
|
|
contentId: currentItem.Id,
|
|
contentUrl: data.url,
|
|
contentType: "video/mp4",
|
|
streamType: MediaStreamType.BUFFERED,
|
|
streamDuration: currentItem.RunTimeTicks
|
|
? currentItem.RunTimeTicks / 10000000
|
|
: undefined,
|
|
customData: currentItem,
|
|
metadata:
|
|
currentItem.Type === "Episode"
|
|
? {
|
|
type: "tvShow",
|
|
title: currentItem.Name || "",
|
|
episodeNumber: currentItem.IndexNumber || 0,
|
|
seasonNumber: currentItem.ParentIndexNumber || 0,
|
|
seriesTitle: currentItem.SeriesName || "",
|
|
images: [
|
|
{
|
|
url: getParentBackdropImageUrl({
|
|
api,
|
|
item: currentItem,
|
|
quality: 90,
|
|
width: 2000,
|
|
})!,
|
|
},
|
|
],
|
|
}
|
|
: currentItem.Type === "Movie"
|
|
? {
|
|
type: "movie",
|
|
title: currentItem.Name || "",
|
|
subtitle: currentItem.Overview || "",
|
|
images: [
|
|
{
|
|
url: getPrimaryImageUrl({
|
|
api,
|
|
item: currentItem,
|
|
quality: 90,
|
|
width: 2000,
|
|
})!,
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
type: "generic",
|
|
title: currentItem.Name || "",
|
|
subtitle: currentItem.Overview || "",
|
|
images: [
|
|
{
|
|
url: getPrimaryImageUrl({
|
|
api,
|
|
item: currentItem,
|
|
quality: 90,
|
|
width: 2000,
|
|
})!,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
startTime: currentPosition, // Resume at same position
|
|
});
|
|
|
|
console.log("[Casting Player] Stream reloaded successfully");
|
|
} catch (error) {
|
|
console.error("[Casting Player] Failed to reload stream:", error);
|
|
}
|
|
},
|
|
[
|
|
api,
|
|
user?.Id,
|
|
currentItem,
|
|
remoteMediaClient,
|
|
mediaStatus?.streamPosition,
|
|
settings.enableH265ForChromecast,
|
|
selectedAudioTrackIndex,
|
|
],
|
|
);
|
|
|
|
// Fetch season data for season poster
|
|
useEffect(() => {
|
|
if (
|
|
currentItem?.Type !== "Episode" ||
|
|
!currentItem.SeasonId ||
|
|
!api ||
|
|
!user?.Id
|
|
)
|
|
return;
|
|
|
|
const fetchSeasonData = async () => {
|
|
try {
|
|
console.log(
|
|
`[Casting Player] Fetching season data for SeasonId: ${currentItem.SeasonId}`,
|
|
);
|
|
const userLibraryApi = getUserLibraryApi(api);
|
|
const response = await userLibraryApi.getItem({
|
|
itemId: currentItem.SeasonId!,
|
|
userId: user.Id!,
|
|
});
|
|
console.log("[Casting Player] Season data fetched:", {
|
|
Id: response.data.Id,
|
|
Name: response.data.Name,
|
|
ImageTags: response.data.ImageTags,
|
|
ParentPrimaryImageItemId: response.data.ParentPrimaryImageItemId,
|
|
});
|
|
setSeasonData(response.data);
|
|
} catch (error) {
|
|
console.error("[Casting Player] Failed to fetch season data:", error);
|
|
setSeasonData(null);
|
|
}
|
|
};
|
|
|
|
fetchSeasonData();
|
|
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
|
|
|
|
const availableAudioTracks = useMemo(() => {
|
|
if (!currentItem?.MediaStreams) return [];
|
|
|
|
return currentItem.MediaStreams.filter(
|
|
(stream) => stream.Type === "Audio",
|
|
).map((stream) => ({
|
|
index: stream.Index ?? 0,
|
|
language: stream.Language || "Unknown",
|
|
displayTitle:
|
|
stream.DisplayTitle ||
|
|
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
|
codec: stream.Codec || "Unknown",
|
|
channels: stream.Channels,
|
|
bitrate: stream.BitRate,
|
|
}));
|
|
}, [currentItem?.MediaStreams]);
|
|
|
|
const availableSubtitleTracks = useMemo(() => {
|
|
if (!currentItem?.MediaStreams) return [];
|
|
|
|
return currentItem.MediaStreams.filter(
|
|
(stream) => stream.Type === "Subtitle",
|
|
).map((stream) => ({
|
|
index: stream.Index ?? 0,
|
|
language: stream.Language || "Unknown",
|
|
displayTitle:
|
|
stream.DisplayTitle ||
|
|
`${stream.Language || "Unknown"}${stream.IsForced ? " (Forced)" : ""}${stream.Title ? ` - ${stream.Title}` : ""}`,
|
|
codec: stream.Codec || "Unknown",
|
|
isForced: stream.IsForced || false,
|
|
isExternal: stream.IsExternal || false,
|
|
}));
|
|
}, [currentItem?.MediaStreams]);
|
|
|
|
const availableMediaSources = useMemo(() => {
|
|
// Get the original source bitrate
|
|
const originalBitrate =
|
|
currentItem?.MediaSources?.[0]?.Bitrate ||
|
|
currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ||
|
|
20000000; // Default to 20Mbps if unknown
|
|
|
|
// Generate bitrate variants
|
|
const variants = [
|
|
{
|
|
id: `${currentItem?.Id}-max`,
|
|
name: "Max",
|
|
bitrate: originalBitrate,
|
|
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
|
|
},
|
|
{
|
|
id: `${currentItem?.Id}-8mbps`,
|
|
name: "8 Mb/s",
|
|
bitrate: 8000000,
|
|
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
|
|
},
|
|
{
|
|
id: `${currentItem?.Id}-4mbps`,
|
|
name: "4 Mb/s",
|
|
bitrate: 4000000,
|
|
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
|
|
},
|
|
{
|
|
id: `${currentItem?.Id}-2mbps`,
|
|
name: "2 Mb/s",
|
|
bitrate: 2000000,
|
|
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
|
|
},
|
|
{
|
|
id: `${currentItem?.Id}-1mbps`,
|
|
name: "1 Mb/s",
|
|
bitrate: 1000000,
|
|
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
|
|
},
|
|
];
|
|
|
|
return variants;
|
|
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
|
|
|
|
// Auto-select stereo audio track for better Chromecast compatibility
|
|
// Note: This only updates the UI state. The actual audio track change requires
|
|
// regenerating the stream URL, which would be disruptive on initial load.
|
|
// The user can manually switch audio tracks if needed.
|
|
useEffect(() => {
|
|
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return;
|
|
|
|
const currentTrack = availableAudioTracks.find(
|
|
(t) => t.index === selectedAudioTrackIndex,
|
|
);
|
|
|
|
// If current track is 5.1+ audio, suggest stereo in the UI
|
|
if (currentTrack && (currentTrack.channels || 0) > 2) {
|
|
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
|
|
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
|
|
console.log(
|
|
"[Audio] Note: 5.1 audio detected. Stereo available:",
|
|
currentTrack.displayTitle,
|
|
"->",
|
|
stereoTrack.displayTitle,
|
|
);
|
|
// Auto-select stereo in UI (user can manually trigger reload)
|
|
setSelectedAudioTrackIndex(stereoTrack.index);
|
|
}
|
|
}
|
|
}, [
|
|
mediaStatus?.mediaInfo,
|
|
availableAudioTracks,
|
|
remoteMediaClient,
|
|
selectedAudioTrackIndex,
|
|
]);
|
|
|
|
// Fetch episodes for TV shows
|
|
useEffect(() => {
|
|
if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api)
|
|
return;
|
|
|
|
const fetchEpisodes = async () => {
|
|
try {
|
|
const tvShowsApi = getTvShowsApi(api);
|
|
// Fetch ALL episodes from ALL seasons by removing seasonId filter
|
|
const response = await tvShowsApi.getEpisodes({
|
|
seriesId: currentItem.SeriesId!,
|
|
// Don't filter by seasonId - get all seasons
|
|
userId: api.accessToken ? undefined : "",
|
|
});
|
|
|
|
const episodeList = response.data.Items || [];
|
|
setEpisodes(episodeList);
|
|
|
|
// Find next episode
|
|
const currentIndex = episodeList.findIndex(
|
|
(ep) => ep.Id === currentItem.Id,
|
|
);
|
|
if (currentIndex >= 0 && currentIndex < episodeList.length - 1) {
|
|
setNextEpisode(episodeList[currentIndex + 1]);
|
|
} else {
|
|
setNextEpisode(null);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch episodes:", error);
|
|
}
|
|
};
|
|
|
|
fetchEpisodes();
|
|
}, [
|
|
currentItem?.Type,
|
|
currentItem?.SeriesId,
|
|
currentItem?.SeasonId,
|
|
currentItem?.Id,
|
|
api,
|
|
]);
|
|
|
|
// Auto-navigate to player when casting starts (if not already on player screen)
|
|
useEffect(() => {
|
|
if (mediaStatus?.currentItemId && !currentItem) {
|
|
// New media started casting while we're not on the player
|
|
console.log("[Casting Player] Auto-navigating to player for new cast");
|
|
router.replace("/casting-player" as any);
|
|
}
|
|
}, [mediaStatus?.currentItemId, currentItem, router]);
|
|
|
|
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
|
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
|
useChromecastSegments(currentItem, progress * 1000, false);
|
|
|
|
// Swipe down to dismiss gesture
|
|
const translateY = useSharedValue(0);
|
|
const context = useSharedValue({ y: 0 });
|
|
|
|
const dismissModal = useCallback(() => {
|
|
// Navigate immediately without animation to prevent crashes
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
}, [router]);
|
|
|
|
const panGesture = Gesture.Pan()
|
|
.onStart(() => {
|
|
context.value = { y: translateY.value };
|
|
})
|
|
.onUpdate((event) => {
|
|
// Only allow downward swipes from top of screen
|
|
if (event.translationY > 0) {
|
|
translateY.value = context.value.y + event.translationY;
|
|
}
|
|
})
|
|
.onEnd((event) => {
|
|
// Dismiss if swiped down more than 150px or fast swipe
|
|
if (event.translationY > 150 || event.velocityY > 600) {
|
|
// Animate down and dismiss
|
|
translateY.value = withSpring(
|
|
1000,
|
|
{
|
|
damping: 20,
|
|
stiffness: 90,
|
|
},
|
|
() => {
|
|
runOnJS(dismissModal)();
|
|
},
|
|
);
|
|
} else {
|
|
// Spring back to position
|
|
translateY.value = withSpring(0);
|
|
}
|
|
});
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateY: translateY.value }],
|
|
}));
|
|
|
|
// Memoize expensive calculations (before early return)
|
|
const posterUrl = useMemo(() => {
|
|
if (!api?.basePath || !currentItem?.Id) return null;
|
|
|
|
// For episodes, use SEASON poster instead of episode poster
|
|
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
|
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
|
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
|
const seasonImageTag = seasonData.ImageTags?.Primary;
|
|
console.log(
|
|
`[Casting Player] Using season poster for ${seasonData.Name}`,
|
|
{ imageItemId, seasonImageTag },
|
|
);
|
|
return seasonImageTag
|
|
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
|
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
|
}
|
|
|
|
// Fallback to item poster for non-episodes or if season data not loaded
|
|
console.log(
|
|
`[Casting Player] Using fallback poster for ${currentItem.Name}`,
|
|
{
|
|
Type: currentItem.Type,
|
|
hasSeasonData: !!seasonData?.Id,
|
|
},
|
|
);
|
|
return getPosterUrl(
|
|
api.basePath,
|
|
currentItem.Id,
|
|
currentItem.ImageTags?.Primary,
|
|
260,
|
|
390,
|
|
);
|
|
}, [
|
|
api?.basePath,
|
|
currentItem?.Id,
|
|
currentItem?.Type,
|
|
seasonData?.Id,
|
|
seasonData?.ImageTags?.Primary,
|
|
currentItem?.ImageTags?.Primary,
|
|
]);
|
|
|
|
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
|
|
const _showNextEpisode = useMemo(() => {
|
|
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
|
|
const remaining = duration - progress;
|
|
return shouldShowNextEpisodeCountdown(remaining, true, 30);
|
|
}, [currentItem?.Type, nextEpisode, duration, progress]);
|
|
|
|
// Redirect if not connected - check CastState like old implementation
|
|
useEffect(() => {
|
|
// Redirect immediately when disconnected or no devices
|
|
if (
|
|
castState === CastState.NOT_CONNECTED ||
|
|
castState === CastState.NO_DEVICES_AVAILABLE
|
|
) {
|
|
// Use setTimeout to avoid state update during render
|
|
const timer = setTimeout(() => {
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [castState]);
|
|
|
|
// Also redirect if mediaStatus disappears (media ended or stopped)
|
|
useEffect(() => {
|
|
if (castState === CastState.CONNECTED && !mediaStatus) {
|
|
const timer = setTimeout(() => {
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
}, 500); // Small delay to allow for media transitions
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [castState, mediaStatus]);
|
|
|
|
// Show loading while connecting
|
|
if (castState === CastState.CONNECTING) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "#000",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<ActivityIndicator size='large' color='#fff' />
|
|
<Text style={{ color: "#fff", marginTop: 16 }}>
|
|
Connecting to Chromecast...
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Don't render if not connected or no media playing
|
|
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Stack.Screen
|
|
options={{
|
|
headerShown: false,
|
|
title: "",
|
|
presentation: "fullScreenModal",
|
|
animation: "slide_from_bottom",
|
|
}}
|
|
/>
|
|
<GestureDetector gesture={panGesture}>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
flex: 1,
|
|
backgroundColor: "#000",
|
|
},
|
|
animatedStyle,
|
|
]}
|
|
>
|
|
{/* Header - Fixed at top */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: insets.top + 8,
|
|
left: 20,
|
|
right: 20,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
<Pressable
|
|
onPress={dismissModal}
|
|
style={{ padding: 8, marginLeft: -8 }}
|
|
>
|
|
<Ionicons name='chevron-down' size={32} color='white' />
|
|
</Pressable>
|
|
|
|
{/* Connection indicator */}
|
|
<Pressable
|
|
onPress={() => setShowDeviceSheet(true)}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
backgroundColor: "#1a1a1a",
|
|
borderRadius: 16,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: protocolColor,
|
|
}}
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: protocolColor,
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
}}
|
|
>
|
|
{currentDevice || "Unknown Device"}
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={() => setShowSettings(true)}
|
|
style={{ padding: 8, marginRight: -8 }}
|
|
>
|
|
<Ionicons name='settings-outline' size={24} color='white' />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Title Area */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: insets.top + 50,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 95,
|
|
alignItems: "center",
|
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
paddingVertical: 16,
|
|
paddingHorizontal: 20,
|
|
}}
|
|
>
|
|
{/* Title */}
|
|
<Text
|
|
style={{
|
|
color: "white",
|
|
fontSize: 20,
|
|
fontWeight: "700",
|
|
textAlign: "center",
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
{truncateTitle(currentItem.Name || "Unknown", 50)}
|
|
</Text>
|
|
|
|
{/* Grey episode/season info */}
|
|
{currentItem.Type === "Episode" &&
|
|
currentItem.ParentIndexNumber !== undefined &&
|
|
currentItem.IndexNumber !== undefined && (
|
|
<Text
|
|
style={{
|
|
color: "#999",
|
|
fontSize: 15,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
{t("casting_player.season_episode_format", {
|
|
season: currentItem.ParentIndexNumber,
|
|
episode: currentItem.IndexNumber,
|
|
})}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Scrollable content area */}
|
|
<ScrollView
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 20,
|
|
paddingTop: insets.top + 160,
|
|
paddingBottom: insets.bottom + 500,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Poster with buffering overlay */}
|
|
<View
|
|
style={{
|
|
alignItems: "center",
|
|
marginBottom: 40,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 280,
|
|
height: 420,
|
|
borderRadius: 12,
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{posterUrl ? (
|
|
<Image
|
|
source={{ uri: posterUrl }}
|
|
style={{ width: "100%", height: "100%" }}
|
|
contentFit='cover'
|
|
/>
|
|
) : (
|
|
<View
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: "#1a1a1a",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='film-outline' size={64} color='#333' />
|
|
</View>
|
|
)}
|
|
|
|
{/* Skip intro/credits bar at bottom of poster */}
|
|
{currentSegment && (
|
|
<Pressable
|
|
onPress={async () => {
|
|
if (!remoteMediaClient) return;
|
|
try {
|
|
const seekFn = async (positionMs: number) => {
|
|
if (
|
|
mediaStatus?.playerState ===
|
|
MediaPlayerState.PLAYING ||
|
|
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
|
) {
|
|
await remoteMediaClient.seek({
|
|
position: positionMs / 1000,
|
|
});
|
|
}
|
|
};
|
|
if (currentSegment.type === "intro") {
|
|
await skipIntro(seekFn);
|
|
} else if (currentSegment.type === "credits") {
|
|
await skipCredits(seekFn);
|
|
} else {
|
|
await skipSegment(seekFn);
|
|
}
|
|
} catch (error) {
|
|
console.error("[Casting Player] Skip error:", error);
|
|
}
|
|
}}
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
backgroundColor: protocolColor,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='play-skip-forward'
|
|
size={18}
|
|
color='white'
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: "white",
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{currentSegment.type === "intro"
|
|
? t("player.skip_intro")
|
|
: currentSegment.type === "credits"
|
|
? t("player.skip_outro")
|
|
: `Skip ${currentSegment.type}`}
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
|
|
{/* Buffering overlay */}
|
|
{isBuffering && (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0,0,0,0.7)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<ActivityIndicator size='large' color={protocolColor} />
|
|
<Text
|
|
style={{
|
|
color: "white",
|
|
fontSize: 16,
|
|
marginTop: 16,
|
|
}}
|
|
>
|
|
{t("casting_player.buffering")}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Fixed 4-button control row for episodes - positioned independently */}
|
|
{currentItem.Type === "Episode" && (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: insets.bottom + 200,
|
|
left: 0,
|
|
right: 0,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
paddingHorizontal: 20,
|
|
}}
|
|
>
|
|
{/* Episodes button */}
|
|
<Pressable
|
|
onPress={() => setShowEpisodeList(true)}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "#1a1a1a",
|
|
padding: 12,
|
|
borderRadius: 12,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='list' size={22} color='white' />
|
|
</Pressable>
|
|
|
|
{/* Previous episode button */}
|
|
<Pressable
|
|
onPress={async () => {
|
|
const currentIndex = episodes.findIndex(
|
|
(ep) => ep.Id === currentItem.Id,
|
|
);
|
|
if (currentIndex > 0 && remoteMediaClient) {
|
|
const previousEp = episodes[currentIndex - 1];
|
|
console.log("Previous episode:", previousEp.Name);
|
|
}
|
|
}}
|
|
disabled={
|
|
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
|
}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "#1a1a1a",
|
|
padding: 12,
|
|
borderRadius: 12,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
opacity:
|
|
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
|
? 0.4
|
|
: 1,
|
|
}}
|
|
>
|
|
<Ionicons name='play-skip-back' size={22} color='white' />
|
|
</Pressable>
|
|
|
|
{/* Next episode button */}
|
|
<Pressable
|
|
onPress={async () => {
|
|
if (nextEpisode && remoteMediaClient) {
|
|
console.log("Next episode:", nextEpisode.Name);
|
|
}
|
|
}}
|
|
disabled={!nextEpisode}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "#1a1a1a",
|
|
padding: 12,
|
|
borderRadius: 12,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
opacity: nextEpisode ? 1 : 0.4,
|
|
}}
|
|
>
|
|
<Ionicons name='play-skip-forward' size={22} color='white' />
|
|
</Pressable>
|
|
|
|
{/* Stop playback button - stops media but stays connected to Chromecast */}
|
|
<Pressable
|
|
onPress={async () => {
|
|
try {
|
|
// Stop the current media playback (don't disconnect from Chromecast)
|
|
if (remoteMediaClient) {
|
|
await remoteMediaClient.stop();
|
|
}
|
|
|
|
// Navigate back/close the player (mini player will disappear since no media is playing)
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[Casting Player] Error stopping playback:",
|
|
error,
|
|
);
|
|
// Navigate anyway
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
}
|
|
}}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "#1a1a1a",
|
|
padding: 12,
|
|
borderRadius: 12,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='stop-circle' size={22} color='white' />
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Fixed bottom controls area */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: insets.bottom + 10,
|
|
left: 20,
|
|
right: 20,
|
|
zIndex: 98,
|
|
}}
|
|
>
|
|
{/* Progress slider with trickplay preview */}
|
|
<View style={{ marginTop: 8, height: 40 }}>
|
|
<Slider
|
|
style={{ width: "100%", height: 40 }}
|
|
progress={sliderProgress}
|
|
minimumValue={sliderMin}
|
|
maximumValue={sliderMax}
|
|
theme={{
|
|
maximumTrackTintColor: "#333",
|
|
minimumTrackTintColor: protocolColor,
|
|
bubbleBackgroundColor: protocolColor,
|
|
bubbleTextColor: "#fff",
|
|
}}
|
|
onSlidingStart={() => {
|
|
isScrubbing.current = true;
|
|
}}
|
|
onValueChange={(value) => {
|
|
// Calculate trickplay preview
|
|
const progressInTicks = msToTicks(value);
|
|
calculateTrickplayUrl(progressInTicks);
|
|
|
|
// Update time display for trickplay bubble
|
|
const progressInSeconds = Math.floor(
|
|
ticksToSeconds(progressInTicks),
|
|
);
|
|
const hours = Math.floor(progressInSeconds / 3600);
|
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
const seconds = progressInSeconds % 60;
|
|
setTrickplayTime({ hours, minutes, seconds });
|
|
|
|
// Track scrub percentage for bubble positioning
|
|
const durationMs = duration * 1000;
|
|
if (durationMs > 0) {
|
|
setScrubPercentage(value / durationMs);
|
|
}
|
|
}}
|
|
onSlidingComplete={(value) => {
|
|
isScrubbing.current = false;
|
|
// Seek to the position (value is in milliseconds, convert to seconds)
|
|
const positionSeconds = value / 1000;
|
|
if (remoteMediaClient && duration > 0) {
|
|
remoteMediaClient
|
|
.seek({ position: positionSeconds })
|
|
.catch((error) => {
|
|
console.error("[Casting Player] Seek error:", error);
|
|
});
|
|
}
|
|
}}
|
|
renderBubble={() => {
|
|
// Calculate bubble position with edge clamping
|
|
const screenWidth = Dimensions.get("window").width;
|
|
const containerPadding = 20; // left/right padding of slider container (matches style)
|
|
const thumbWidth = 16; // matches thumbWidth prop on Slider
|
|
const sliderWidth = screenWidth - containerPadding * 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 = 80;
|
|
// Clamp position so bubble stays on screen
|
|
// minLeft prevents going off left edge, maxLeft prevents going off right edge
|
|
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: 20,
|
|
left: clampedLeft,
|
|
backgroundColor: protocolColor,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderRadius: 6,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: "#fff",
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
|
|
trickplayTime.minutes < 10
|
|
? `0${trickplayTime.minutes}`
|
|
: trickplayTime.minutes
|
|
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const { x, y, url } = trickPlayUrl;
|
|
const tileWidth = 220; // Larger preview for casting player
|
|
const tileHeight =
|
|
tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
|
|
|
// Calculate clamped position for trickplay preview
|
|
// minLeft: furthest left (when thumb is at left edge)
|
|
// maxLeft: furthest right (when thumb is at right edge)
|
|
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: 20,
|
|
left: clampedLeft,
|
|
width: tileWidth,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{/* Trickplay image preview */}
|
|
<View
|
|
style={{
|
|
width: tileWidth,
|
|
height: tileHeight,
|
|
borderRadius: 8,
|
|
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: 4,
|
|
left: 4,
|
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: "#fff",
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
|
|
trickplayTime.minutes < 10
|
|
? `0${trickplayTime.minutes}`
|
|
: trickplayTime.minutes
|
|
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}}
|
|
sliderHeight={6}
|
|
thumbWidth={16}
|
|
panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }}
|
|
/>
|
|
</View>
|
|
|
|
{/* Time display */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
{formatTime(progress * 1000)}
|
|
</Text>
|
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
Ending at{" "}
|
|
{calculateEndingTime(progress * 1000, duration * 1000)}
|
|
</Text>
|
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
{formatTime(duration * 1000)}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Playback controls */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: 32,
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
{/* Rewind (use settings) */}
|
|
<Pressable
|
|
onPress={() => skipBackward(settings?.rewindSkipTime ?? 10)}
|
|
style={{
|
|
position: "relative",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='refresh-outline'
|
|
size={48}
|
|
color='white'
|
|
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
|
/>
|
|
{settings?.rewindSkipTime && (
|
|
<Text
|
|
style={{
|
|
position: "absolute",
|
|
color: "white",
|
|
fontSize: 15,
|
|
fontWeight: "bold",
|
|
bottom: 10,
|
|
}}
|
|
>
|
|
{settings.rewindSkipTime}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
|
|
{/* Play/Pause */}
|
|
<Pressable
|
|
onPress={togglePlayPause}
|
|
style={{
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 36,
|
|
backgroundColor: protocolColor,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={isPlaying ? "pause" : "play"}
|
|
size={36}
|
|
color='white'
|
|
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
|
/>
|
|
</Pressable>
|
|
|
|
{/* Forward (use settings) */}
|
|
<Pressable
|
|
onPress={() => skipForward(settings?.forwardSkipTime ?? 10)}
|
|
style={{
|
|
position: "relative",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='refresh-outline' size={48} color='white' />
|
|
{settings?.forwardSkipTime && (
|
|
<Text
|
|
style={{
|
|
position: "absolute",
|
|
color: "white",
|
|
fontSize: 15,
|
|
fontWeight: "bold",
|
|
bottom: 10,
|
|
}}
|
|
>
|
|
{settings.forwardSkipTime}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Modals */}
|
|
<ChromecastDeviceSheet
|
|
visible={showDeviceSheet && protocol === "chromecast"}
|
|
onClose={() => setShowDeviceSheet(false)}
|
|
device={
|
|
currentDevice && protocol === "chromecast" && castDevice
|
|
? ({
|
|
deviceId: castDevice.deviceId,
|
|
friendlyName: currentDevice,
|
|
} as any)
|
|
: null
|
|
}
|
|
onDisconnect={async () => {
|
|
try {
|
|
// End the casting session and disconnect completely
|
|
const sessionManager = GoogleCast.getSessionManager();
|
|
await sessionManager.endCurrentSession(true);
|
|
setShowDeviceSheet(false);
|
|
// Close player immediately after disconnecting
|
|
setTimeout(() => {
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
}, 100);
|
|
} catch (error) {
|
|
console.error(
|
|
"[Casting Player] Error disconnecting from Chromecast:",
|
|
error,
|
|
);
|
|
}
|
|
}}
|
|
volume={volume}
|
|
onVolumeChange={async (vol) => {
|
|
setVolume(vol);
|
|
}}
|
|
/>
|
|
|
|
<ChromecastEpisodeList
|
|
visible={showEpisodeList}
|
|
onClose={() => setShowEpisodeList(false)}
|
|
currentItem={currentItem}
|
|
episodes={episodes}
|
|
api={api}
|
|
onSelectEpisode={(episode) => {
|
|
// TODO: Load new episode - requires casting new media
|
|
console.log("Selected episode:", episode.Name);
|
|
setShowEpisodeList(false);
|
|
}}
|
|
/>
|
|
|
|
<ChromecastSettingsMenu
|
|
visible={showSettings}
|
|
onClose={() => setShowSettings(false)}
|
|
item={currentItem}
|
|
mediaSources={availableMediaSources.filter((source) => {
|
|
const currentBitrate =
|
|
availableMediaSources[0]?.bitrate || Number.POSITIVE_INFINITY;
|
|
return (source.bitrate || 0) <= currentBitrate;
|
|
})}
|
|
selectedMediaSource={availableMediaSources[0] || null}
|
|
onMediaSourceChange={(source) => {
|
|
// Reload stream with new bitrate
|
|
console.log("Changed media source:", source);
|
|
reloadWithSettings({ bitrateValue: source.bitrate });
|
|
}}
|
|
audioTracks={availableAudioTracks}
|
|
selectedAudioTrack={
|
|
selectedAudioTrackIndex !== null
|
|
? availableAudioTracks.find(
|
|
(t) => t.index === selectedAudioTrackIndex,
|
|
) || null
|
|
: availableAudioTracks[0] || null
|
|
}
|
|
onAudioTrackChange={(track) => {
|
|
setSelectedAudioTrackIndex(track.index);
|
|
// Reload stream with new audio track
|
|
reloadWithSettings({ audioIndex: track.index });
|
|
}}
|
|
subtitleTracks={availableSubtitleTracks}
|
|
selectedSubtitleTrack={
|
|
selectedSubtitleTrackIndex !== null
|
|
? availableSubtitleTracks.find(
|
|
(t) => t.index === selectedSubtitleTrackIndex,
|
|
) || null
|
|
: null
|
|
}
|
|
onSubtitleTrackChange={(track) => {
|
|
setSelectedSubtitleTrackIndex(track?.index ?? null);
|
|
// Reload stream with new subtitle track
|
|
reloadWithSettings({ subtitleIndex: track?.index ?? null });
|
|
}}
|
|
playbackSpeed={currentPlaybackSpeed}
|
|
onPlaybackSpeedChange={(speed) => {
|
|
setCurrentPlaybackSpeed(speed);
|
|
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
|
}}
|
|
/>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</>
|
|
);
|
|
}
|