mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-25 02:36:35 +00:00
Fixes several issues and enhances the Chromecast casting experience: - Prevents errors when loading media by slimming down the customData payload to avoid exceeding message size limits. - Improves logic for selecting custom data from media status. - Fixes an issue with subtitle track selection. - Recommends stereo audio tracks for better Chromecast compatibility. - Improves volume control and mute synchronization between the app and the Chromecast device. - Adds error handling for `loadMedia` in `PlayButton`. - Fixes image caching issue for season posters in mini player. - Implements cleanup for scroll retry timeout in episode list. - Ensures segment skipping functions are asynchronous. - Resets `hasReportedStartRef` after stopping casting. - Prevents seeking past the end of Outro segments. - Reports playback progress more accurately by also taking player state changes into account.
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
};
|