mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-25 08:16:43 +01:00
fix: design
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
useJellyseerr,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import type {
|
||||
MovieResult,
|
||||
@@ -35,7 +36,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
const router = useRouter();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
const posterUrl = item.posterPath
|
||||
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
|
||||
@@ -44,6 +45,10 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
const title = getTitle(item);
|
||||
const year = getYear(item);
|
||||
|
||||
const isInLibrary =
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||
|
||||
const handlePress = () => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||
@@ -65,23 +70,21 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 180,
|
||||
width: 210,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 180,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 12,
|
||||
width: 210,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
@@ -107,13 +110,30 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isInLibrary && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginTop: 10,
|
||||
marginTop: 12,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
@@ -122,10 +142,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
@@ -61,7 +60,6 @@ interface TVCastCardProps {
|
||||
};
|
||||
imageProxy: (path: string, size?: string) => string;
|
||||
onPress: () => void;
|
||||
isFirst?: boolean;
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
@@ -69,7 +67,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
person,
|
||||
imageProxy,
|
||||
onPress,
|
||||
isFirst,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
@@ -85,7 +82,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -174,6 +170,7 @@ interface TVSeasonCardProps {
|
||||
canRequest: boolean;
|
||||
disabled?: boolean;
|
||||
onCardFocus?: () => void;
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
@@ -182,6 +179,7 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
canRequest,
|
||||
disabled = false,
|
||||
onCardFocus,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
@@ -194,6 +192,7 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={canRequest ? onPress : undefined}
|
||||
onFocus={handleCardFocus}
|
||||
onBlur={handleBlur}
|
||||
@@ -205,7 +204,8 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
animatedStyle,
|
||||
{
|
||||
minWidth: 180,
|
||||
padding: 16,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
@@ -221,42 +221,46 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<View style={{ height: 40, justifyContent: "center" }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
})}
|
||||
</Text>
|
||||
<JellyseerrStatusIcon
|
||||
mediaStatus={season.status}
|
||||
showRequestIcon={canRequest}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
{t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
})}
|
||||
</Text>
|
||||
<JellyseerrStatusIcon
|
||||
mediaStatus={season.status}
|
||||
showRequestIcon={canRequest}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused ? "rgba(255,255,255,0.8)" : "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
})}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -279,9 +283,10 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const { showRequestModal } = useTVRequestModal();
|
||||
const [lastActionButtonRef, setLastActionButtonRef] = useState<View | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Refs for TVFocusGuideView destinations (useState triggers re-render when set)
|
||||
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
||||
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
|
||||
|
||||
// Scroll control ref
|
||||
const mainScrollRef = useRef<ScrollView>(null);
|
||||
@@ -757,7 +762,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
onPress={handlePlay}
|
||||
hasTVPreferredFocus
|
||||
variant='primary'
|
||||
refSetter={!canRequest ? setLastActionButtonRef : undefined}
|
||||
refSetter={setPlayButtonRef}
|
||||
>
|
||||
<Ionicons
|
||||
name='play'
|
||||
@@ -777,12 +782,13 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
</TVButton>
|
||||
)}
|
||||
|
||||
{canRequest && (
|
||||
{/* Request button - only show for movies, TV series use Request All + season cards */}
|
||||
{canRequest && mediaType === MediaType.MOVIE && (
|
||||
<TVButton
|
||||
onPress={handleRequest}
|
||||
variant='secondary'
|
||||
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||
refSetter={setLastActionButtonRef}
|
||||
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
@@ -801,6 +807,62 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
</Text>
|
||||
</TVButton>
|
||||
)}
|
||||
|
||||
{/* Request All button for TV series */}
|
||||
{mediaType === MediaType.TV &&
|
||||
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
||||
!allSeasonsAvailable && (
|
||||
<TVButton
|
||||
onPress={handleRequestAll}
|
||||
variant='secondary'
|
||||
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: 40,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='bag-add'
|
||||
size={20}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.request_all")}
|
||||
</Text>
|
||||
</View>
|
||||
</TVButton>
|
||||
)}
|
||||
|
||||
{/* Individual season cards for TV series */}
|
||||
{mediaType === MediaType.TV &&
|
||||
orderBy(
|
||||
seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"desc",
|
||||
).map((season) => {
|
||||
const canRequestSeason =
|
||||
season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<TVSeasonCard
|
||||
key={season.id}
|
||||
season={season}
|
||||
onPress={() => handleSeasonRequest(season.seasonNumber)}
|
||||
canRequest={canRequestSeason}
|
||||
onCardFocus={handleSeasonsFocus}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Approve/Decline for managers */}
|
||||
@@ -867,67 +929,6 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Seasons section (TV shows only) */}
|
||||
{mediaType === MediaType.TV &&
|
||||
seasons.filter((s) => s.seasonNumber !== 0).length > 0 && (
|
||||
<View style={{ marginTop: 40, marginBottom: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("item_card.seasons")}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{ paddingVertical: 16, gap: 16 }}
|
||||
>
|
||||
{!allSeasonsAvailable && (
|
||||
<TVButton onPress={handleRequestAll} variant='secondary'>
|
||||
<Ionicons
|
||||
name='bag-add'
|
||||
size={20}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.request_all")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
)}
|
||||
{orderBy(
|
||||
seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"desc",
|
||||
).map((season) => {
|
||||
const canRequestSeason =
|
||||
season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<TVSeasonCard
|
||||
key={season.id}
|
||||
season={season}
|
||||
onPress={() => handleSeasonRequest(season.seasonNumber)}
|
||||
canRequest={canRequestSeason}
|
||||
onCardFocus={handleSeasonsFocus}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Cast section */}
|
||||
{cast.length > 0 && jellyseerrApi && (
|
||||
<View style={{ marginTop: 24 }}>
|
||||
@@ -942,35 +943,51 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
{t("jellyseerr.cast")}
|
||||
</Text>
|
||||
|
||||
{/* Focus guide for upward navigation from cast to action buttons */}
|
||||
{lastActionButtonRef && (
|
||||
{/* Focus guides for bidirectional navigation - stacked together */}
|
||||
{/* Downward: action buttons → first cast card */}
|
||||
{firstCastCardRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[lastActionButtonRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
destinations={[firstCastCardRef]}
|
||||
style={{
|
||||
height: 1,
|
||||
width: SCREEN_WIDTH,
|
||||
marginLeft: -(insets.left + 80),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Upward: cast → action buttons */}
|
||||
{playButtonRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[playButtonRef]}
|
||||
style={{
|
||||
height: 1,
|
||||
width: SCREEN_WIDTH,
|
||||
marginLeft: -(insets.left + 80),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
<ScrollView
|
||||
horizontal
|
||||
data={cast}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: 16,
|
||||
gap: 28,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
>
|
||||
{cast.map((person, index) => (
|
||||
<TVCastCard
|
||||
person={item}
|
||||
key={person.id}
|
||||
person={person}
|
||||
imageProxy={(path, size) =>
|
||||
jellyseerrApi.imageProxy(path, size || "w185")
|
||||
}
|
||||
onPress={() => handleCastPress(item.id)}
|
||||
isFirst={index === 0}
|
||||
onPress={() => handleCastPress(person.id)}
|
||||
refSetter={index === 0 ? setFirstCastCardRef : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
Reference in New Issue
Block a user