fix: ios 26 design improvements (#1097)

This commit is contained in:
Fredrik Burmester
2025-09-29 11:22:39 +02:00
committed by GitHub
parent 26c99cfc3d
commit af6b18546e
15 changed files with 488 additions and 258 deletions

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
@@ -11,6 +11,18 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { Platform, TouchableOpacity } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -42,6 +42,22 @@ export function Chromecast({
[Platform.OS],
);
if (Platform.OS === "ios") {
return (
<TouchableOpacity
className='mr-4'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</TouchableOpacity>
);
}
if (background === "transparent")
return (
<RoundButton

View File

@@ -105,13 +105,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
if (!Platform.isTV) {
navigation.setOptions({
headerRight: () =>
item && (
item &&
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>
)}
</View>
) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast
background='blur'
width={22}
height={22}
/>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
@@ -126,7 +140,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View>
)}
</View>
),
)),
});
}
}, [item, navigation, user]);

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton";
@@ -14,6 +14,21 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);
}}
size={props.size}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton

View File

@@ -10,6 +10,7 @@ interface Props extends ViewProps {
background?: boolean;
size?: "default" | "large";
fillColor?: "primary";
color?: "white" | "purple";
hapticFeedback?: boolean;
}
@@ -20,6 +21,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
children,
size = "default",
fillColor,
color = "white",
hapticFeedback = true,
...viewProps
}) => {
@@ -34,6 +36,25 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
onPress?.();
};
if (Platform.OS === "ios") {
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
}
if (fillColor)
return (
<TouchableOpacity

View File

@@ -19,6 +19,18 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
const router = useRouter();
if (Platform.OS === "ios") {
return (
<TouchableOpacity
onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
</TouchableOpacity>
);
}
if (background === "transparent" && Platform.OS !== "android")
return (
<TouchableOpacity

View File

@@ -0,0 +1,254 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface LibraryOptions {
display: "row" | "list";
imageStyle: "poster" | "cover";
showTitles: boolean;
showStats: boolean;
}
interface Props extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
settings: LibraryOptions;
updateSettings: (options: Partial<LibraryOptions>) => void;
disabled?: boolean;
}
const OptionGroup: React.FC<{ title: string; children: React.ReactNode }> = ({
title,
children,
}) => (
<View className='mb-6'>
<Text className='text-lg font-semibold mb-3 text-neutral-300'>{title}</Text>
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{children}
</View>
</View>
);
const OptionItem: React.FC<{
label: string;
selected: boolean;
onPress: () => void;
disabled?: boolean;
isLast?: boolean;
}> = ({ label, selected, onPress, disabled: itemDisabled, isLast }) => (
<>
<TouchableOpacity
onPress={onPress}
disabled={itemDisabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
itemDisabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{label}</Text>
{selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
const ToggleItem: React.FC<{
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
isLast?: boolean;
}> = ({ label, value, onToggle, disabled: itemDisabled, isLast }) => (
<>
<TouchableOpacity
onPress={onToggle}
disabled={itemDisabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
itemDisabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{label}</Text>
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
/**
* LibraryOptionsSheet Component
*
* This component creates a bottom sheet modal for managing library display options.
*/
export const LibraryOptionsSheet: React.FC<Props> = ({
open,
setOpen,
settings,
updateSettings,
disabled = false,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handlePresentModal = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
const handleDismissModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
useEffect(() => {
if (open) {
handlePresentModal();
} else {
handleDismissModal();
}
}, [open, handlePresentModal, handleDismissModal]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (disabled) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetView>
<View
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<Text className='font-bold text-2xl mb-6'>
{t("library.options.display")}
</Text>
<OptionGroup title={t("library.options.display")}>
<OptionItem
label={t("library.options.row")}
selected={settings.display === "row"}
onPress={() => updateSettings({ display: "row" })}
/>
<OptionItem
label={t("library.options.list")}
selected={settings.display === "list"}
onPress={() => updateSettings({ display: "list" })}
isLast
/>
</OptionGroup>
<OptionGroup title={t("library.options.image_style")}>
<OptionItem
label={t("library.options.poster")}
selected={settings.imageStyle === "poster"}
onPress={() => updateSettings({ imageStyle: "poster" })}
/>
<OptionItem
label={t("library.options.cover")}
selected={settings.imageStyle === "cover"}
onPress={() => updateSettings({ imageStyle: "cover" })}
isLast
/>
</OptionGroup>
<OptionGroup title='Options'>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}
onToggle={() =>
updateSettings({ showTitles: !settings.showTitles })
}
disabled={settings.imageStyle === "poster"}
/>
<ToggleItem
label={t("library.options.show_stats")}
value={settings.showStats}
onToggle={() =>
updateSettings({ showStats: !settings.showStats })
}
isLast
/>
</OptionGroup>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -14,6 +14,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerShown: true,
headerTransparent: true,
headerShadowVisible: false,
headerBlurEffect: "none",
headerLeft: () => <HeaderBackButton />,
};