mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-17 06:46:26 +00:00
fix: ios 26 design improvements (#1097)
This commit is contained in:
committed by
GitHub
parent
26c99cfc3d
commit
af6b18546e
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
254
components/settings/LibraryOptionsSheet.tsx
Normal file
254
components/settings/LibraryOptionsSheet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect: "none",
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user