Files
streamyfin_mirror/components/ItemTechnicalDetails.tsx
Alex ca92f61900 refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
Co-authored-by: storm1er <le.storm1er@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com>
Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com>
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-15 21:34:22 +02:00

243 lines
7.0 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { formatBitrate } from "@/utils/bitrate";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
interface Props {
source?: MediaSourceInfo;
}
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={["80%"]}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>
<View>
<Text className='text-lg font-bold mb-2'>
{t("item_card.audio")}
</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio",
) || []
}
/>
</View>
<View>
<Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle",
) || []
}
/>
</View>
</View>
</BottomSheetScrollView>
</BottomSheetModal>
</View>
);
};
const SubtitleStreamInfo = ({
subtitleStreams,
}: {
subtitleStreams: MediaStream[];
}) => {
return (
<View className='flex flex-col'>
{subtitleStreams.map((stream, _index) => (
<View key={stream.Index} className='flex flex-col'>
<Text className='text-xs mb-3 text-neutral-400'>
{stream.DisplayTitle}
</Text>
<View className='flex flex-row flex-wrap gap-2'>
<Badge
variant='gray'
iconLeft={
<Ionicons name='language-outline' size={16} color='white' />
}
text={stream.Language}
/>
<Badge
variant='gray'
text={stream.Codec}
iconLeft={
<Ionicons name='layers-outline' size={16} color='white' />
}
/>
</View>
</View>
))}
</View>
);
};
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className='flex flex-col'>
{audioStreams.map((audioStreams, index) => (
<View key={index} className='flex flex-col'>
<Text className='mb-3 text-neutral-400 text-xs'>
{audioStreams.DisplayTitle}
</Text>
<View className='flex-row flex-wrap gap-2'>
<Badge
variant='gray'
iconLeft={
<Ionicons name='language-outline' size={16} color='white' />
}
text={audioStreams.Language}
/>
<Badge
variant='gray'
iconLeft={
<Ionicons
name='musical-notes-outline'
size={16}
color='white'
/>
}
text={audioStreams.Codec}
/>
<Badge
variant='gray'
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
text={audioStreams.ChannelLayout}
/>
<Badge
variant='gray'
iconLeft={
<Ionicons name='speedometer-outline' size={16} color='white' />
}
text={formatBitrate(audioStreams.BitRate)}
/>
</View>
</View>
))}
</View>
);
};
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => {
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
| MediaStream
| undefined;
}, [source?.MediaStreams]);
if (!source || !videoStream) return null;
return (
<View className='flex-row flex-wrap gap-2'>
<Badge
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={formatFileSize(source.Size)}
/>
<Badge
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
<Badge
variant='gray'
iconLeft={
<Ionicons name='color-palette-outline' size={16} color='white' />
}
text={videoStream.VideoRange}
/>
<Badge
variant='gray'
iconLeft={
<Ionicons name='code-working-outline' size={16} color='white' />
}
text={videoStream.Codec}
/>
<Badge
variant='gray'
iconLeft={
<Ionicons name='speedometer-outline' size={16} color='white' />
}
text={formatBitrate(videoStream.BitRate)}
/>
<Badge
variant='gray'
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
text={
videoStream.AverageFrameRate != null
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
: ""
}
/>
</View>
);
};
const formatFileSize = (bytes?: number | null) => {
if (!bytes) return "N/A";
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
10,
);
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};